ГОВНОЧИСТ, ГОВНОЧИСТ ГОВНОЧИСТ. ОХ ГОВНА Я КОНЕЧНО УНЕС

This commit is contained in:
Jganenok
2026-06-07 14:06:35 +07:00
parent a80cba572f
commit 32d29a7c25
32 changed files with 1827 additions and 1605 deletions
+46 -24
View File
@@ -5,6 +5,7 @@ import '../api.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart'; import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart'; import '../../models/attachment.dart';
import 'chats.dart' show ChatsModule; import 'chats.dart' show ChatsModule;
@@ -24,7 +25,8 @@ class ContactCache {
static String? get(int id) => _nameCache[id]; static String? get(int id) => _nameCache[id];
static String? getAvatar(int id) => _avatarCache[id]; static String? getAvatar(int id) => _avatarCache[id];
static Set<String>? getOptions(int id) => _optionsCache[id]; static Set<String>? getOptions(int id) => _optionsCache[id];
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; static bool isOfficial(int id) =>
_optionsCache[id]?.contains('OFFICIAL') ?? false;
static void clear() { static void clear() {
_nameCache.clear(); _nameCache.clear();
@@ -81,13 +83,13 @@ class FileHistoryEntry {
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'fileId': fileId, 'fileId': fileId,
if (url != null) 'url': url, if (url != null) 'url': url,
if (token != null) 'token': token, if (token != null) 'token': token,
if (filename != null) 'filename': filename, if (filename != null) 'filename': filename,
if (size != null) 'size': size, if (size != null) 'size': size,
'sentAt': sentAt.millisecondsSinceEpoch, 'sentAt': sentAt.millisecondsSinceEpoch,
}; };
static FileHistoryEntry? fromJson(Map<String, dynamic> j) { static FileHistoryEntry? fromJson(Map<String, dynamic> j) {
final id = j['fileId']; final id = j['fileId'];
@@ -108,8 +110,9 @@ class FileHistoryCache {
static const _prefKey = 'file_history_v1'; static const _prefKey = 'file_history_v1';
static const _maxEntries = 50; static const _maxEntries = 50;
static final ValueNotifier<List<FileHistoryEntry>> notifier = static final ValueNotifier<List<FileHistoryEntry>> notifier = ValueNotifier(
ValueNotifier(const []); const [],
);
static List<FileHistoryEntry> get history => notifier.value; static List<FileHistoryEntry> get history => notifier.value;
static bool get isEmpty => notifier.value.isEmpty; static bool get isEmpty => notifier.value.isEmpty;
@@ -135,7 +138,10 @@ class FileHistoryCache {
} }
static void add(FileHistoryEntry entry) { static void add(FileHistoryEntry entry) {
final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)]; final next = [
entry,
...notifier.value.where((e) => e.fileId != entry.fileId),
];
if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length); if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length);
notifier.value = next; notifier.value = next;
_persist(); _persist();
@@ -223,15 +229,24 @@ class CachedMessage {
return CachedMessage( return CachedMessage(
id: row['id']?.toString() ?? '', id: row['id']?.toString() ?? '',
accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, accountId: row['account_id'] is int
chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, ? row['account_id'] as int
senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, : int.tryParse(row['account_id']?.toString() ?? '') ?? 0,
chatId: row['chat_id'] is int
? row['chat_id'] as int
: int.tryParse(row['chat_id']?.toString() ?? '') ?? 0,
senderId: row['sender_id'] is int
? row['sender_id'] as int
: int.tryParse(row['sender_id']?.toString() ?? '') ?? 0,
text: row['text']?.toString(), text: row['text']?.toString(),
time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0, time: row['time'] is int
? row['time'] as int
: int.tryParse(row['time']?.toString() ?? '') ?? 0,
status: row['status']?.toString(), status: row['status']?.toString(),
payload: payload, payload: payload,
attachments: attachments, attachments: attachments,
isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false, isControl:
attachments?.any((a) => a.type == AttachmentType.control) ?? false,
); );
} }
@@ -252,8 +267,7 @@ class CachedMessage {
if (attaches is List && attaches.isNotEmpty) { if (attaches is List && attaches.isNotEmpty) {
attachments = attaches attachments = attaches
.whereType<Map>() .whereType<Map>()
.map((a) => .map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList(); .toList();
} }
return CachedMessage( return CachedMessage(
@@ -327,7 +341,7 @@ class MessagesModule {
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
AppDatabase.saveMessages(rows).catchError((e) { AppDatabase.saveMessages(rows).catchError((e) {
debugPrint('saveMessages error: $e'); logger.e('saveMessages error: $e');
}); });
} }
@@ -424,7 +438,9 @@ class MessagesModule {
final response = await _api.sendRequest(Opcode.msgSend, payload); final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) { if (!response.isOk) {
final msg = (response.payload is Map) final msg = (response.payload is Map)
? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') ? (response.payload['localizedMessage'] ??
response.payload['message'] ??
'Ошибка отправки')
: 'Ошибка отправки'; : 'Ошибка отправки';
throw Exception(msg.toString()); throw Exception(msg.toString());
} }
@@ -460,7 +476,10 @@ class MessagesModule {
if (transcriptionStatus == 1) { if (transcriptionStatus == 1) {
final text = data['transcription'] as String? ?? ''; final text = data['transcription'] as String? ?? '';
if (text.isEmpty) { if (text.isEmpty) {
return TranscriptionResult(status: 1, text: 'не удалось распознать текст'); return TranscriptionResult(
status: 1,
text: 'не удалось распознать текст',
);
} }
return TranscriptionResult(status: 1, text: text); return TranscriptionResult(status: 1, text: text);
} }
@@ -509,7 +528,7 @@ class MessagesModule {
if (token != null) if (token != null)
{'_type': 'FILE', 'token': token} {'_type': 'FILE', 'token': token}
else else
{'_type': 'FILE', 'fileId': fileId} {'_type': 'FILE', 'fileId': fileId},
], ],
}, },
'notify': notify, 'notify': notify,
@@ -706,7 +725,10 @@ class MessagesModule {
final rawOpts = contact['options']; final rawOpts = contact['options'];
if (rawOpts is List) { if (rawOpts is List) {
ContactCache.putOptions(contactId, rawOpts.whereType<String>().toSet()); ContactCache.putOptions(
contactId,
rawOpts.whereType<String>().toSet(),
);
} }
ChatsModule.applyContactUpdate(contactId); ChatsModule.applyContactUpdate(contactId);
@@ -716,7 +738,7 @@ class MessagesModule {
} }
} }
} catch (e) { } catch (e) {
debugPrint('searchContactById error: $e'); logger.e('searchContactById error: $e');
} }
return null; return null;
} }
+95
View File
@@ -0,0 +1,95 @@
/// Shared formatting helpers (dates, durations, sizes, phone, gender).
library;
const List<String> kRuMonthsShort = [
'янв',
'фев',
'мар',
'апр',
'мая',
'июн',
'июл',
'авг',
'сен',
'окт',
'ноя',
'дек',
];
String _two(int n) => n.toString().padLeft(2, '0');
/// "512 Б" / "1.5 КБ" / "3.2 МБ" / "1.1 ГБ" — Cyrillic units, 1 decimal.
String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes Б';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ГБ';
}
/// "m:ss" (e.g. "3:07"); with [padMinutes] the minutes are zero-padded ("03:07").
String formatDurationMmSs(Duration d, {bool padMinutes = false}) {
final m = d.inMinutes;
return '${padMinutes ? _two(m) : m}:${_two(d.inSeconds % 60)}';
}
/// "m:ss" from a raw seconds count.
String formatSecondsMmSs(int seconds, {bool padMinutes = false}) =>
formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes);
/// "HH:mm".
String formatClock(DateTime dt) => '${_two(dt.hour)}:${_two(dt.minute)}';
/// "5 мая 2024".
String formatDateWords(DateTime dt) =>
'${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}';
/// "05.04.2024".
String formatDateNumeric(DateTime dt) =>
'${_two(dt.day)}.${_two(dt.month)}.${dt.year}';
/// "05.04.2024 14:30".
String formatDateTimeNumeric(DateTime dt) =>
'${formatDateNumeric(dt)} ${formatClock(dt)}';
/// "5 мая 2024, 14:30".
String formatDateTimeWords(DateTime dt) =>
'${formatDateWords(dt)}, ${formatClock(dt)}';
/// "Был(-а) только что / N мин назад / N ч назад / N дн назад / 5 мая 2024".
String formatLastSeen(int secondsSinceEpoch) {
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 2) return 'Был(-а) только что';
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
return 'Был(-а) ${formatDateWords(dt)}';
}
/// "+7 (912) 345-67-89" for RU numbers, "+digits" otherwise.
/// Accepts an int phone or a string; returns null if there is no usable number.
String? formatPhone(dynamic raw) {
String? digits;
if (raw is int && raw > 0) {
digits = raw.toString();
} else if (raw is String && raw.isNotEmpty && raw != '***') {
digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isEmpty) return null;
}
if (digits == null) return null;
if (digits.length == 11 && digits.startsWith('7')) {
return '+${digits[0]} (${digits.substring(1, 4)}) '
'${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9)}';
}
return '+$digits';
}
/// 1 → "Мужской", 2 → "Женский", anything else → null.
String? formatGender(dynamic raw) {
if (raw is! int) return null;
if (raw == 1) return 'Мужской';
if (raw == 2) return 'Женский';
return null;
}
+3
View File
@@ -4,6 +4,9 @@ import 'package:image/image.dart' as img;
const int _avatarMaxDimension = 1024; const int _avatarMaxDimension = 1024;
const int _avatarTargetBytes = 900 * 1024; const int _avatarTargetBytes = 900 * 1024;
/// Maximum accepted size for a user-picked avatar before compression.
const int kMaxAvatarBytes = 8 * 1024 * 1024;
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input); Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
Uint8List? _encodeAvatar(Uint8List input) { Uint8List? _encodeAvatar(Uint8List input) {
+20 -28
View File
@@ -16,6 +16,7 @@ import '../profile/spoof_screen.dart';
import '../profile/debug_menu_screen.dart'; import '../profile/debug_menu_screen.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/adaptive_shell.dart'; import '../../widgets/adaptive_shell.dart';
import '../../widgets/sheet_helpers.dart';
import '../../../backend/api.dart'; import '../../../backend/api.dart';
import '../../../main.dart'; import '../../../main.dart';
@@ -152,9 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) { builder: (sheetContext) {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -188,7 +187,9 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
KometApp.stateOf(appContext)?.applyLocale(const Locale('ru')); KometApp.stateOf(
appContext,
)?.applyLocale(const Locale('ru'));
}, },
), ),
ListTile( ListTile(
@@ -202,7 +203,9 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
KometApp.stateOf(appContext)?.applyLocale(const Locale('en')); KometApp.stateOf(
appContext,
)?.applyLocale(const Locale('en'));
}, },
), ),
], ],
@@ -220,9 +223,7 @@ class _LoginScreenState extends State<LoginScreen> {
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) { builder: (context) {
double progress = _isTOSRead ? 1.0 : 0.0; double progress = _isTOSRead ? 1.0 : 0.0;
return StatefulBuilder( return StatefulBuilder(
@@ -503,13 +504,9 @@ class _LoginScreenState extends State<LoginScreen> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) { builder: (_) {
return SafeArea( return SafeArea(child: const ServerSettingsSheet());
child: const ServerSettingsSheet(),
);
}, },
); );
} }
@@ -520,13 +517,9 @@ class _LoginScreenState extends State<LoginScreen> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) { builder: (_) {
return SafeArea( return SafeArea(child: const ProxySettingsSheet());
child: const ProxySettingsSheet(),
);
}, },
); );
} }
@@ -537,9 +530,7 @@ class _LoginScreenState extends State<LoginScreen> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) { builder: (sheetContext) {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -614,9 +605,7 @@ class _LoginScreenState extends State<LoginScreen> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) { builder: (context) {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -725,7 +714,8 @@ class _LoginScreenState extends State<LoginScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
onPressed: () => _showSecurityOptions(context), onPressed: () =>
_showSecurityOptions(context),
icon: Icon( icon: Icon(
Symbols.admin_panel_settings, Symbols.admin_panel_settings,
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
@@ -840,7 +830,9 @@ class _LoginScreenState extends State<LoginScreen> {
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: _phoneMaskHint(_selectedCountry), hintText: _phoneMaskHint(
_selectedCountry,
),
hintStyle: TextStyle( hintStyle: TextStyle(
color: cs.outline, color: cs.outline,
fontSize: 15, fontSize: 15,
@@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
class ProxySettingsSheet extends StatefulWidget { class ProxySettingsSheet extends StatefulWidget {
const ProxySettingsSheet({super.key}); const ProxySettingsSheet({super.key});
@@ -57,13 +58,15 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
try { try {
final username = _usernameController.text.trim(); final username = _usernameController.text.trim();
final password = _passwordController.text.trim(); final password = _passwordController.text.trim();
await ProxyConfig.save(ProxySettings( await ProxyConfig.save(
type: _selectedType, ProxySettings(
host: host, type: _selectedType,
port: port, host: host,
username: username.isNotEmpty ? username : null, port: port,
password: password.isNotEmpty ? password : null, username: username.isNotEmpty ? username : null,
)); password: password.isNotEmpty ? password : null,
),
);
await api.disconnect(); await api.disconnect();
await api.connect(); await api.connect();
if (!mounted) return; if (!mounted) return;
@@ -120,16 +123,8 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Center( const Center(
child: Container( child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)),
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
),
), ),
Text( Text(
l10n.proxySettingsTitle, l10n.proxySettingsTitle,
@@ -197,9 +192,7 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: _busy ? null : () => _apply(l10n), onPressed: _busy ? null : () => _apply(l10n),
child: Text( child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable),
isActive ? l10n.proxyApply : l10n.proxyDisable,
),
), ),
], ],
), ),
@@ -278,10 +271,7 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
enabled: !_busy, enabled: !_busy,
obscureText: obscureText, obscureText: obscureText,
style: GoogleFonts.inter( style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15),
color: cs.onSurface,
fontSize: 15,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: GoogleFonts.inter( hintStyle: GoogleFonts.inter(
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
class ServerSettingsSheet extends StatefulWidget { class ServerSettingsSheet extends StatefulWidget {
const ServerSettingsSheet({super.key}); const ServerSettingsSheet({super.key});
@@ -57,10 +58,13 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
await api.disconnect(); await api.disconnect();
unawaited(api.connect()); unawaited(api.connect());
final online = await api.stateStream final online = await api.stateStream
.firstWhere((s) => .firstWhere(
s == SessionState.online || s == SessionState.disconnected) (s) => s == SessionState.online || s == SessionState.disconnected,
.timeout(const Duration(seconds: 15), )
onTimeout: () => SessionState.disconnected); .timeout(
const Duration(seconds: 15),
onTimeout: () => SessionState.disconnected,
);
if (!mounted) return; if (!mounted) return;
if (online == SessionState.online) { if (online == SessionState.online) {
showCustomNotification(context, l10n.serverSettingsSaved); showCustomNotification(context, l10n.serverSettingsSaved);
@@ -83,10 +87,13 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
await api.disconnect(); await api.disconnect();
api.connect(); api.connect();
final online = await api.stateStream final online = await api.stateStream
.firstWhere((s) => .firstWhere(
s == SessionState.online || s == SessionState.disconnected) (s) => s == SessionState.online || s == SessionState.disconnected,
.timeout(const Duration(seconds: 15), )
onTimeout: () => SessionState.disconnected); .timeout(
const Duration(seconds: 15),
onTimeout: () => SessionState.disconnected,
);
if (!mounted) return; if (!mounted) return;
if (online == SessionState.online) { if (online == SessionState.online) {
showCustomNotification(context, l10n.serverSettingsSaved); showCustomNotification(context, l10n.serverSettingsSaved);
@@ -119,16 +126,8 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Center( const Center(
child: Container( child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)),
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
),
), ),
Text( Text(
l10n.serverSettingsTitle, l10n.serverSettingsTitle,
@@ -153,9 +152,7 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
hintText: '${ServerConfig.defaultPort}', hintText: '${ServerConfig.defaultPort}',
cs: cs, cs: cs,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
FilteringTextInputFormatter.digitsOnly,
],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton( FilledButton(
@@ -199,10 +196,7 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
enabled: !_busy, enabled: !_busy,
style: GoogleFonts.inter( style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15),
color: cs.onSurface,
fontSize: 15,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: GoogleFonts.inter( hintStyle: GoogleFonts.inter(
+6 -18
View File
@@ -4,6 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/format.dart';
enum CallScreenState { incoming, outgoing, active } enum CallScreenState { incoming, outgoing, active }
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
@@ -68,12 +70,6 @@ class _CallScreenState extends State<CallScreen>
}); });
} }
String get _timerText {
final m = (_seconds ~/ 60).toString().padLeft(2, '0');
final s = (_seconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
void _accept() { void _accept() {
setState(() { setState(() {
_state = CallScreenState.active; _state = CallScreenState.active;
@@ -127,13 +123,8 @@ class _CallScreenState extends State<CallScreen>
return AnimatedBuilder( return AnimatedBuilder(
animation: _pulseAnimation, animation: _pulseAnimation,
builder: (context, child) { builder: (context, child) {
final scale = (isRinging || isOutgoing) final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0;
? _pulseAnimation.value return Transform.scale(scale: scale, child: child);
: 1.0;
return Transform.scale(
scale: scale,
child: child,
);
}, },
child: Container( child: Container(
width: size, width: size,
@@ -204,7 +195,7 @@ class _CallScreenState extends State<CallScreen>
case CallScreenState.outgoing: case CallScreenState.outgoing:
text = 'Вызов...'; text = 'Вызов...';
case CallScreenState.active: case CallScreenState.active:
text = _timerText; text = formatSecondsMmSs(_seconds, padMinutes: true);
} }
return Text( return Text(
text, text,
@@ -322,10 +313,7 @@ class _ActionButton extends StatelessWidget {
Container( Container(
width: 64, width: 64,
height: 64, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(shape: BoxShape.circle, color: color),
shape: BoxShape.circle,
color: color,
),
alignment: Alignment.center, alignment: Alignment.center,
child: Icon(icon, color: Colors.white, size: 28, fill: 1), child: Icon(icon, color: Colors.white, size: 28, fill: 1),
), ),
+7 -43
View File
@@ -1,9 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show api; import '../../../main.dart' show api;
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../backend/modules/calls.dart'; import '../../../backend/modules/calls.dart';
import '../../widgets/komet_avatar.dart';
class CallsTab extends StatefulWidget { class CallsTab extends StatefulWidget {
const CallsTab({super.key}); const CallsTab({super.key});
@@ -81,36 +82,7 @@ class _CallsTabState extends State<CallsTab> {
String _formatDate(int timestamp) { String _formatDate(int timestamp) {
if (timestamp == 0) return ''; if (timestamp == 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final months = [ return '${dt.day} ${kRuMonthsShort[dt.month - 1]}';
'янв.',
'фев.',
'мар.',
'апр.',
'мая',
'июн.',
'июл.',
'авг.',
'сен.',
'окт.',
'ноя.',
'дек.',
];
return '${dt.day} ${months[dt.month - 1]}';
}
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
return Container(
color: cs.primaryContainer,
alignment: Alignment.center,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
);
} }
Widget _buildCallItem( Widget _buildCallItem(
@@ -165,18 +137,10 @@ class _CallsTabState extends State<CallsTab> {
width: 1, width: 1,
), ),
), ),
child: ClipOval( child: KometAvatar(
child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty name: call.name,
? CachedNetworkImage( imageUrl: call.avatarUrl,
imageUrl: call.avatarUrl!, size: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, call.name),
)
: _buildPlaceholderAvatar(cs, call.name),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
+164 -141
View File
@@ -5,6 +5,8 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache; import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart'; import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../widgets/komet_avatar.dart';
class _MemberInfo { class _MemberInfo {
final int id; final int id;
@@ -223,7 +225,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const SizedBox(height: 4), const SizedBox(height: 4),
_buildAvatar(cs), KometAvatar(
name: widget.name,
imageUrl: widget.imageUrl,
size: 96,
fontSize: 36,
),
const SizedBox(height: 14), const SizedBox(height: 14),
Text( Text(
widget.name, widget.name,
@@ -253,39 +260,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
); );
} }
// ─── AVATAR ──────────────────────────────────────────────────────────────
Widget _buildAvatar(ColorScheme cs) {
return Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle, color: cs.primaryContainer),
child: widget.imageUrl.isNotEmpty
? ClipOval(
child: CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.cover,
memCacheWidth: 360,
memCacheHeight: 360,
errorWidget: (context, error, stack) => _avatarLetters(cs),
),
)
: _avatarLetters(cs),
);
}
Widget _avatarLetters(ColorScheme cs) => Center(
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
);
// ─── SUBTITLE ──────────────────────────────────────────────────────────── // ─── SUBTITLE ────────────────────────────────────────────────────────────
String _subtitle() { String _subtitle() {
@@ -392,10 +366,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
} else { } else {
final phone = _contactData?['phone']; final phone = _contactData?['phone'];
final phoneInt = final phoneInt = phone is int
phone is int ? phone : int.tryParse(phone?.toString() ?? ''); ? phone
: int.tryParse(phone?.toString() ?? '');
if (phoneInt != null && phoneInt > 0) { if (phoneInt != null && phoneInt > 0) {
items.add(_simpleInfoCard(cs, 'Номер телефона', _formatPhone(phoneInt))); items.add(
_simpleInfoCard(cs, 'Номер телефона', formatPhone(phoneInt)!),
);
} }
} }
} else if (widget.chatType == 'CHANNEL') { } else if (widget.chatType == 'CHANNEL') {
@@ -417,8 +394,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
); );
} }
Widget _simpleInfoCard(ColorScheme cs, String label, String value, Widget _simpleInfoCard(
{bool isLink = false}) { ColorScheme cs,
String label,
String value, {
bool isLink = false,
}) {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
@@ -429,8 +410,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
@@ -458,19 +441,27 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Ссылка-приглашение', Text(
style: 'Ссылка-приглашение',
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(link, Text(
style: const TextStyle( link,
color: Color(0xFF007AFF), fontSize: 15)), style: const TextStyle(
color: Color(0xFF007AFF),
fontSize: 15,
),
),
], ],
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.qr_code_2, icon: const Icon(
color: Color(0xFF007AFF), size: 22), Icons.qr_code_2,
color: Color(0xFF007AFF),
size: 22,
),
onPressed: () {}, onPressed: () {},
), ),
], ],
@@ -492,16 +483,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Описание', Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), 'Описание',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
desc, desc,
style: style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
maxLines: (_descExpanded || !isLong) ? null : collapsedLines, maxLines: (_descExpanded || !isLong) ? null : collapsedLines,
overflow: overflow: (_descExpanded || !isLong) ? null : TextOverflow.ellipsis,
(_descExpanded || !isLong) ? null : TextOverflow.ellipsis,
), ),
if (isLong) ...[ if (isLong) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -509,8 +500,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
onTap: () => setState(() => _descExpanded = !_descExpanded), onTap: () => setState(() => _descExpanded = !_descExpanded),
child: Text( child: Text(
_descExpanded ? 'Свернуть' : 'Ещё', _descExpanded ? 'Свернуть' : 'Ещё',
style: const TextStyle( style: const TextStyle(color: Color(0xFF007AFF), fontSize: 13),
color: Color(0xFF007AFF), fontSize: 13),
), ),
), ),
], ],
@@ -598,10 +588,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
if (_selectedTab.isEmpty) return const SizedBox.shrink(); if (_selectedTab.isEmpty) return const SizedBox.shrink();
return AnimatedSwitcher( return AnimatedSwitcher(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
child: KeyedSubtree( child: KeyedSubtree(key: ValueKey(_selectedTab), child: _tabBody(cs)),
key: ValueKey(_selectedTab),
child: _tabBody(cs),
),
); );
} }
@@ -632,11 +619,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, Icon(
color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48), icon,
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
size: 48,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(label, Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
], ],
), ),
); );
@@ -648,7 +640,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
final items = <Widget>[]; final items = <Widget>[];
if (widget.chatType == 'DIALOG' && !_isBot) { if (widget.chatType == 'DIALOG' && !_isBot) {
final bio = (_contactData?['description'] as String?) ?? final bio =
(_contactData?['description'] as String?) ??
(_contactData?['about'] as String?); (_contactData?['about'] as String?);
if (bio != null && bio.isNotEmpty) { if (bio != null && bio.isNotEmpty) {
items items
@@ -696,14 +689,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(value, Text(
style: TextStyle( value,
color: cs.onSurface, style: TextStyle(
fontSize: 16, color: cs.onSurface,
fontWeight: FontWeight.w500)), fontSize: 16,
fontWeight: FontWeight.w500,
),
),
], ],
), ),
); );
@@ -727,7 +725,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
Widget _memberAction( Widget _memberAction(
ColorScheme cs, IconData icon, String label, VoidCallback onTap) { ColorScheme cs,
IconData icon,
String label,
VoidCallback onTap,
) {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@@ -737,8 +739,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
children: [ children: [
Icon(icon, color: const Color(0xFF007AFF), size: 26), Icon(icon, color: const Color(0xFF007AFF), size: 26),
const SizedBox(width: 14), const SizedBox(width: 14),
Text(label, Text(label, style: TextStyle(color: cs.onSurface, fontSize: 16)),
style: TextStyle(color: cs.onSurface, fontSize: 16)),
], ],
), ),
), ),
@@ -746,11 +747,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
Widget _listDivider(ColorScheme cs) => Divider( Widget _listDivider(ColorScheme cs) => Divider(
height: 1, height: 1,
indent: 56, indent: 56,
endIndent: 0, endIndent: 0,
color: cs.outlineVariant.withValues(alpha: 0.3), color: cs.outlineVariant.withValues(alpha: 0.3),
); );
Widget _memberTile(ColorScheme cs, _MemberInfo member) { Widget _memberTile(ColorScheme cs, _MemberInfo member) {
final name = final name =
@@ -768,8 +769,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
sublabel = 'Был(-а) недавно'; sublabel = 'Был(-а) недавно';
} }
final String? roleLabel = final String? roleLabel = member.isOwner
member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null); ? 'владелец'
: (member.isAdmin ? 'Адмін' : null);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
@@ -778,7 +780,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
(avatar != null && avatar.isNotEmpty) (avatar != null && avatar.isNotEmpty)
? CircleAvatar( ? CircleAvatar(
radius: 22, radius: 22,
backgroundImage: CachedNetworkImageProvider(avatar, maxWidth: 144, maxHeight: 144), backgroundImage: CachedNetworkImageProvider(
avatar,
maxWidth: 144,
maxHeight: 144,
),
backgroundColor: cs.primaryContainer, backgroundColor: cs.primaryContainer,
) )
: CircleAvatar( : CircleAvatar(
@@ -787,7 +793,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Text( child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?', name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle( style: TextStyle(
color: cs.onPrimaryContainer, fontSize: 16), color: cs.onPrimaryContainer,
fontSize: 16,
),
), ),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
@@ -795,21 +803,26 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(name, Text(
style: TextStyle( name,
color: cs.onSurface, style: TextStyle(
fontSize: 15, color: cs.onSurface,
fontWeight: FontWeight.w500)), fontSize: 15,
Text(sublabel, fontWeight: FontWeight.w500,
style: TextStyle( ),
color: cs.onSurfaceVariant, fontSize: 13)), ),
Text(
sublabel,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
], ],
), ),
), ),
if (roleLabel != null) if (roleLabel != null)
Text(roleLabel, Text(
style: roleLabel,
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
], ],
), ),
); );
@@ -821,8 +834,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
final rows = <({String label, String value})>[]; final rows = <({String label, String value})>[];
final chat = _chatData; final chat = _chatData;
if (chat == null) { if (chat == null) {
return Text('Нет данных', return Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); 'Нет данных',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
);
} }
void add(String label, dynamic val, {bool tsFormat = false}) { void add(String label, dynamic val, {bool tsFormat = false}) {
@@ -830,7 +845,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
if (val is bool && !val) return; if (val is bool && !val) return;
String str; String str;
if (tsFormat && val is int && val > 1) { if (tsFormat && val is int && val > 1) {
str = _formatTs(val); str = formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(val));
} else if (val is bool) { } else if (val is bool) {
str = 'да'; str = 'да';
} else { } else {
@@ -887,8 +902,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
if (rows.isEmpty) { if (rows.isEmpty) {
return Text('Нет данных', return Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); 'Нет данных',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
);
} }
final extraRows = _buildExtraContactRows(); final extraRows = _buildExtraContactRows();
@@ -908,18 +925,21 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
rows[i].value, rows[i].value,
trailing: _trailingFor(rows[i].label, cs), trailing: _trailingFor(rows[i].label, cs),
), ),
if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty)) if (i < rows.length - 1 ||
(_extraContactExpanded && extraRows.isNotEmpty))
Divider( Divider(
height: 10, height: 10,
color: cs.outlineVariant.withValues(alpha: 0.25)), color: cs.outlineVariant.withValues(alpha: 0.25),
),
], ],
if (_extraContactExpanded) if (_extraContactExpanded)
for (int i = 0; i < extraRows.length; i++) ...[ for (int i = 0; i < extraRows.length; i++) ...[
_infoRow(cs, extraRows[i].label, extraRows[i].value), _infoRow(cs, extraRows[i].label, extraRows[i].value),
if (i < extraRows.length - 1) if (i < extraRows.length - 1)
Divider( Divider(
height: 10, height: 10,
color: cs.outlineVariant.withValues(alpha: 0.25)), color: cs.outlineVariant.withValues(alpha: 0.25),
),
], ],
], ],
), ),
@@ -932,11 +952,17 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
final rows = <({String label, String value})>[]; final rows = <({String label, String value})>[];
final reg = c['registrationTime']; final reg = c['registrationTime'];
if (reg is int && reg > 0) { if (reg is int && reg > 0) {
rows.add((label: 'Регистрация', value: _formatTs(reg))); rows.add((
label: 'Регистрация',
value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(reg)),
));
} }
final upd = c['updateTime']; final upd = c['updateTime'];
if (upd is int && upd > 0) { if (upd is int && upd > 0) {
rows.add((label: 'Обновлён', value: _formatTs(upd))); rows.add((
label: 'Обновлён',
value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(upd)),
));
} }
final country = c['country']; final country = c['country'];
if (country is String && country.isNotEmpty) { if (country is String && country.isNotEmpty) {
@@ -944,7 +970,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
final gender = c['gender']; final gender = c['gender'];
if (gender is int) { if (gender is int) {
final g = gender == 1 ? 'Мужской' : (gender == 2 ? 'Женский' : null); final g = formatGender(gender);
if (g != null) rows.add((label: 'Пол', value: g)); if (g != null) rows.add((label: 'Пол', value: g));
} }
final phone = c['phone']; final phone = c['phone'];
@@ -981,11 +1007,17 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded), onPressed: () =>
setState(() => _extraContactExpanded = !_extraContactExpanded),
); );
} }
Widget _infoRow(ColorScheme cs, String label, String value, {Widget? trailing}) { Widget _infoRow(
ColorScheme cs,
String label,
String value, {
Widget? trailing,
}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
@@ -995,13 +1027,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, Text(
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), label,
Text(value, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
style: TextStyle( ),
color: cs.onSurface, Text(
fontSize: 12, value,
fontWeight: FontWeight.w500)), style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
], ],
), ),
), ),
@@ -1015,13 +1052,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
Widget _buildShimmer(ColorScheme cs) { Widget _buildShimmer(ColorScheme cs) {
Widget block(double w, double h, {double r = 8}) => Container( Widget block(double w, double h, {double r = 8}) => Container(
width: w, width: w,
height: h, height: h,
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(r), borderRadius: BorderRadius.circular(r),
), ),
); );
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), padding: const EdgeInsets.fromLTRB(16, 60, 16, 0),
@@ -1044,7 +1081,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
// ─── HELPERS ───────────────────────────────────────────────────────────── // ─── HELPERS ─────────────────────────────────────────────────────────────
String _formatLastSeen(int secondsSinceEpoch) { String _formatLastSeen(int secondsSinceEpoch) {
final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; final diff =
DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000;
if (diff < 60000) return 'только что'; if (diff < 60000) return 'только что';
if (diff < 3600000) return '${diff ~/ 60000} мин назад'; if (diff < 3600000) return '${diff ~/ 60000} мин назад';
if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; if (diff < 86400000) return '${diff ~/ 3600000} ч назад';
@@ -1052,21 +1090,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
return 'давно'; return 'давно';
} }
String _formatPhone(int phone) {
final s = phone.toString();
if (s.length == 11 && s.startsWith('7')) {
return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-'
'${s.substring(7, 9)}-${s.substring(9, 11)}';
}
return '+$s';
}
String _formatTs(int ts) {
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
String _pluralCount(int n, String one, String few, String many) { String _pluralCount(int n, String one, String few, String many) {
final mod100 = n % 100; final mod100 = n % 100;
final mod10 = n % 10; final mod10 = n % 10;
@@ -10,8 +10,10 @@ import 'chat_screen.dart';
import 'create_group_flow.dart'; import 'create_group_flow.dart';
import '../../widgets/adaptive_shell.dart'; import '../../widgets/adaptive_shell.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
import '../../widgets/sliding_pill_nav.dart'; import '../../widgets/sliding_pill_nav.dart';
import '../../../core/utils/format.dart';
import '../calls/calls_tab.dart'; import '../calls/calls_tab.dart';
import '../contacts/contacts_tab.dart'; import '../contacts/contacts_tab.dart';
@@ -342,9 +344,7 @@ class _ChatListScreenState extends State<ChatListScreen>
return showModalBottomSheet<bool>( return showModalBottomSheet<bool>(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) { builder: (ctx) {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -832,10 +832,7 @@ class _ChatListScreenState extends State<ChatListScreen>
String _formatTime(int? timestamp) { String _formatTime(int? timestamp) {
if (timestamp == null || timestamp == 0) return ''; if (timestamp == null || timestamp == 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); return formatClock(DateTime.fromMillisecondsSinceEpoch(timestamp));
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
return '$h:$m';
} }
Widget _buildChatShimmer() { Widget _buildChatShimmer() {
File diff suppressed because it is too large Load Diff
@@ -11,20 +11,17 @@ import '../../../core/storage/token_storage.dart';
import '../../../core/utils/image_utils.dart'; import '../../../core/utils/image_utils.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
const int _maxAvatarBytes = 8 * 1024 * 1024;
Future<void> showCreateGroupFlow(BuildContext context) async { Future<void> showCreateGroupFlow(BuildContext context) async {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => const _CreateGroupFlow(), builder: (_) => const _CreateGroupFlow(),
); );
} }
@@ -70,7 +67,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
} }
final list = await ContactsModule.getContacts(myId); final list = await ContactsModule.getContacts(myId);
list.removeWhere((c) => c.id == myId); list.removeWhere((c) => c.id == myId);
list.sort((a, b) => _displayName(a).toLowerCase().compareTo(_displayName(b).toLowerCase())); list.sort(
(a, b) => _displayName(
a,
).toLowerCase().compareTo(_displayName(b).toLowerCase()),
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_all = list; _all = list;
@@ -102,7 +103,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
if (path == null) return; if (path == null) return;
final file = File(path); final file = File(path);
final size = await file.length(); final size = await file.length();
if (size > _maxAvatarBytes) { if (size > kMaxAvatarBytes) {
if (!mounted) return; if (!mounted) return;
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
return; return;
@@ -134,7 +135,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
if (url != null) { if (url != null) {
final bytes = await compressAvatar(await _avatar!.readAsBytes()); final bytes = await compressAvatar(await _avatar!.readAsBytes());
if (bytes == null) { if (bytes == null) {
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку'); if (mounted) {
showCustomNotification(context, 'Не удалось обработать аватарку');
}
} else { } else {
final token = await fileUploader.uploadImage( final token = await fileUploader.uploadImage(
Uri.parse(url), Uri.parse(url),
@@ -142,7 +145,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
filename: 'avatar.jpg', filename: 'avatar.jpg',
); );
if (token != null) { if (token != null) {
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); await ChatsModule.setChatPhoto(
api,
chatId: chat.id,
photoToken: token,
);
} else if (mounted) { } else if (mounted) {
showCustomNotification(context, 'Не удалось загрузить аватарку'); showCustomNotification(context, 'Не удалось загрузить аватарку');
} }
@@ -183,7 +190,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
padding: EdgeInsets.only(bottom: viewInsets.bottom), padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: SafeArea( child: SafeArea(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut, switchInCurve: Curves.easeOut,
@@ -193,7 +202,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
? Offset(-0.05, 0) ? Offset(-0.05, 0)
: Offset(0.05, 0); : Offset(0.05, 0);
return SlideTransition( return SlideTransition(
position: Tween<Offset>(begin: offset, end: Offset.zero).animate(anim), position: Tween<Offset>(
begin: offset,
end: Offset.zero,
).animate(anim),
child: FadeTransition(opacity: anim, child: child), child: FadeTransition(opacity: anim, child: child),
); );
}, },
@@ -217,7 +229,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
final query = _search.text.trim().toLowerCase(); final query = _search.text.trim().toLowerCase();
final filtered = query.isEmpty final filtered = query.isEmpty
? _all ? _all
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList(); : _all
.where((c) => _displayName(c).toLowerCase().contains(query))
.toList();
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -269,7 +283,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Найти по имени', hintText: 'Найти по имени',
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
prefixIcon: Icon(Symbols.search, color: cs.onSurfaceVariant, size: 20), prefixIcon: Icon(
Symbols.search,
color: cs.onSurfaceVariant,
size: 20,
),
isDense: true, isDense: true,
border: InputBorder.none, border: InputBorder.none,
), ),
@@ -291,7 +309,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
return InkWell( return InkWell(
onTap: () => _toggle(c), onTap: () => _toggle(c),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row( child: Row(
children: [ children: [
_Avatar(contact: c, size: 40, cs: cs), _Avatar(contact: c, size: 40, cs: cs),
@@ -316,7 +337,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
Text( Text(
_statusText(c), _statusText(c),
style: TextStyle( style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.8), color: cs.onSurfaceVariant.withValues(
alpha: 0.8,
),
fontSize: 12, fontSize: 12,
), ),
maxLines: 1, maxLines: 1,
@@ -333,7 +356,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
color: cs.primary, color: cs.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Symbols.check, color: cs.onPrimary, size: 16), child: Icon(
Symbols.check,
color: cs.onPrimary,
size: 16,
),
), ),
], ],
), ),
@@ -419,7 +446,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: _avatar != null child: _avatar != null
? Image.file(_avatar!, fit: BoxFit.cover) ? Image.file(_avatar!, fit: BoxFit.cover)
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20), : Icon(
Symbols.add_a_photo,
color: cs.onSurfaceVariant,
size: 20,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -431,7 +462,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
style: TextStyle(color: cs.onSurface, fontSize: 16), style: TextStyle(color: cs.onSurface, fontSize: 16),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Название группы', hintText: 'Название группы',
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
), ),
@@ -500,7 +534,10 @@ class _Avatar extends StatelessWidget {
return Container( return Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration(color: cs.primaryContainer, shape: BoxShape.circle), decoration: BoxDecoration(
color: cs.primaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
initial, initial,
@@ -589,8 +626,8 @@ class _SheetButton extends StatelessWidget {
color: filled color: filled
? cs.onPrimary ? cs.onPrimary
: (disabled : (disabled
? cs.onSurface.withValues(alpha: 0.4) ? cs.onSurface.withValues(alpha: 0.4)
: cs.onSurface), : cs.onSurface),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -1,11 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/cache/info_cache.dart'; import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../../../core/utils/format.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
import '../chats/chat_screen.dart'; import '../chats/chat_screen.dart';
@@ -96,64 +97,10 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
if (_isBot) return 'Бот'; if (_isBot) return 'Бот';
if (_presenceStatus == 1) return 'В сети'; if (_presenceStatus == 1) return 'В сети';
if (_presenceStatus == 3) return 'Был(-а) недавно'; if (_presenceStatus == 3) return 'Был(-а) недавно';
if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!); if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!);
return ''; return '';
} }
String _formatLastSeen(int secondsSinceEpoch) {
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 2) return 'Был(-а) только что';
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
return 'Был(-а) ${_formatDate(dt)}';
}
String _formatDate(DateTime dt) {
const months = [
'янв', 'фев', 'мар', 'апр', 'мая', 'июн',
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
];
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
}
String _formatDateTime(int msSinceEpoch) {
final dt = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch);
final hh = dt.hour.toString().padLeft(2, '0');
final mm = dt.minute.toString().padLeft(2, '0');
return '${_formatDate(dt)}, $hh:$mm';
}
String? _formatPhone(dynamic raw) {
String? digits;
if (raw is int && raw > 0) {
digits = raw.toString();
} else if (raw is String && raw.isNotEmpty && raw != '***') {
digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isEmpty) return null;
}
if (digits == null) return null;
if (digits.length == 11 && digits.startsWith('7')) {
final p = digits;
return '+${p[0]} (${p.substring(1, 4)}) ${p.substring(4, 7)}-${p.substring(7, 9)}-${p.substring(9)}';
}
return '+$digits';
}
String? _formatGender(dynamic raw) {
if (raw is! int) return null;
switch (raw) {
case 1:
return 'Мужской';
case 2:
return 'Женский';
default:
return null;
}
}
Future<void> _openChat() async { Future<void> _openChat() async {
final accountId = await TokenStorage.getActiveAccountId(); final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return; if (accountId == null) return;
@@ -204,7 +151,12 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
children: [ children: [
_buildAvatar(cs), KometAvatar(
name: _displayName(),
imageUrl: _avatarUrl(),
size: 96,
fontSize: 36,
),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildNameRow(cs), _buildNameRow(cs),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -225,41 +177,6 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
); );
} }
Widget _buildAvatar(ColorScheme cs) {
final url = _avatarUrl();
return Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primaryContainer,
),
child: (url != null && url.isNotEmpty)
? ClipOval(
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
errorWidget: (_, _, _) => _avatarLetters(cs),
),
)
: _avatarLetters(cs),
);
}
Widget _avatarLetters(ColorScheme cs) {
final name = _displayName();
return Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildNameRow(ColorScheme cs) { Widget _buildNameRow(ColorScheme cs) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -280,12 +197,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
), ),
if (_isVerified) ...[ if (_isVerified) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Icon( Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1),
Symbols.verified,
color: cs.primary,
size: 20,
fill: 1,
),
], ],
], ],
); );
@@ -295,8 +207,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
final actions = <({IconData icon, String label, VoidCallback? onTap})>[ final actions = <({IconData icon, String label, VoidCallback? onTap})>[
(icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat), (icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat),
(icon: Symbols.notifications, label: 'Звук', onTap: null), (icon: Symbols.notifications, label: 'Звук', onTap: null),
if (!_isBot) if (!_isBot) (icon: Symbols.call, label: 'Звонок', onTap: null),
(icon: Symbols.call, label: 'Звонок', onTap: null),
]; ];
return Row( return Row(
children: [ children: [
@@ -336,7 +247,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
final rows = <Widget>[]; final rows = <Widget>[];
final phoneStr = _formatPhone(c['phone']); final phoneStr = formatPhone(c['phone']);
if (phoneStr != null) { if (phoneStr != null) {
rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr)); rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr));
} }
@@ -346,24 +257,45 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
rows.add(_infoRow(cs, Symbols.public, 'Страна', country)); rows.add(_infoRow(cs, Symbols.public, 'Страна', country));
} }
final genderStr = _formatGender(c['gender']); final genderStr = formatGender(c['gender']);
if (genderStr != null) { if (genderStr != null) {
rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr)); rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr));
} }
final regTime = c['registrationTime'] as int?; final regTime = c['registrationTime'] as int?;
if (regTime != null && regTime > 0) { if (regTime != null && regTime > 0) {
rows.add(_infoRow(cs, Symbols.event, 'Регистрация', _formatDateTime(regTime))); rows.add(
_infoRow(
cs,
Symbols.event,
'Регистрация',
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)),
),
);
} }
final updateTime = c['updateTime'] as int?; final updateTime = c['updateTime'] as int?;
if (updateTime != null && updateTime > 0) { if (updateTime != null && updateTime > 0) {
rows.add(_infoRow(cs, Symbols.update, 'Обновлён', _formatDateTime(updateTime))); rows.add(
_infoRow(
cs,
Symbols.update,
'Обновлён',
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)),
),
);
} }
final accountStatus = c['accountStatus']; final accountStatus = c['accountStatus'];
if (accountStatus is int && accountStatus != 0) { if (accountStatus is int && accountStatus != 0) {
rows.add(_infoRow(cs, Symbols.account_circle, 'Статус аккаунта', accountStatus.toString())); rows.add(
_infoRow(
cs,
Symbols.account_circle,
'Статус аккаунта',
accountStatus.toString(),
),
);
} }
final desc = (c['description'] as String?)?.trim(); final desc = (c['description'] as String?)?.trim();
@@ -383,7 +315,9 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
final opts = _options(); final opts = _options();
if (opts.isNotEmpty) { if (opts.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true)); rows.add(
_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true),
);
} }
rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString())); rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString()));
@@ -401,7 +335,10 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
children: [ children: [
for (var i = 0; i < rows.length; i++) ...[ for (var i = 0; i < rows.length; i++) ...[
if (i > 0) if (i > 0)
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), Divider(
height: 1,
color: cs.outlineVariant.withValues(alpha: 0.3),
),
rows[i], rows[i],
], ],
], ],
+32 -37
View File
@@ -1,4 +1,3 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
@@ -6,6 +5,8 @@ import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/contacts.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/sheet_helpers.dart';
import 'contact_profile_screen.dart'; import 'contact_profile_screen.dart';
class ContactsTab extends StatefulWidget { class ContactsTab extends StatefulWidget {
@@ -31,9 +32,7 @@ class _ContactsTabState extends State<ContactsTab> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => const _SearchContactSheet(), builder: (_) => const _SearchContactSheet(),
); );
} }
@@ -55,21 +54,6 @@ class _ContactsTabState extends State<ContactsTab> {
} }
} }
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
return Container(
color: cs.primaryContainer,
alignment: Alignment.center,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildContactItem( Widget _buildContactItem(
BuildContext context, BuildContext context,
ColorScheme cs, ColorScheme cs,
@@ -109,18 +93,10 @@ class _ContactsTabState extends State<ContactsTab> {
width: 1, width: 1,
), ),
), ),
child: ClipOval( child: KometAvatar(
child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty name: nameToDisplay,
? CachedNetworkImage( imageUrl: contact.baseUrl,
imageUrl: contact.baseUrl!, size: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, nameToDisplay),
)
: _buildPlaceholderAvatar(cs, nameToDisplay),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -366,8 +342,15 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
style: TextStyle(color: cs.onSurface, fontSize: 16), style: TextStyle(color: cs.onSurface, fontSize: 16),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Введите ID контакта', hintText: 'Введите ID контакта',
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), hintStyle: TextStyle(
prefixIcon: Icon(Symbols.tag, color: cs.onSurfaceVariant, size: 20), color: cs.onSurfaceVariant,
fontSize: 16,
),
prefixIcon: Icon(
Symbols.tag,
color: cs.onSurfaceVariant,
size: 20,
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
@@ -380,19 +363,29 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.errorContainer.withValues(alpha: 0.5), color: cs.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
children: [ children: [
Icon(Symbols.error_outline, size: 18, color: cs.onErrorContainer), Icon(
Symbols.error_outline,
size: 18,
color: cs.onErrorContainer,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
_error!, _error!,
style: TextStyle(color: cs.onErrorContainer, fontSize: 13), style: TextStyle(
color: cs.onErrorContainer,
fontSize: 13,
),
), ),
), ),
], ],
@@ -403,7 +396,9 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
FilledButton( FilledButton(
onPressed: _loading ? null : _submit, onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
child: _loading child: _loading
@@ -11,8 +11,10 @@ import '../../../backend/modules/chats.dart';
import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/cloud_storage.dart';
import '../../../backend/modules/upload_manager.dart'; import '../../../backend/modules/upload_manager.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
enum _EnvState { loading, notConfigured, ready } enum _EnvState { loading, notConfigured, ready }
@@ -108,7 +110,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id); final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id);
if (cachedId != null) { if (cachedId != null) {
final rows = await ChatsModule.getChat(profile.id, cachedId); final rows = await ChatsModule.getChat(profile.id, cachedId);
if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) { if (rows.isNotEmpty &&
CloudStorageModule.isCloudStorageGroup(rows.first)) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_envState = _EnvState.ready; _envState = _EnvState.ready;
@@ -127,7 +130,10 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
final orphans = CloudStorageModule.findOrphanGroups(chats); final orphans = CloudStorageModule.findOrphanGroups(chats);
if (envGroup == null && orphans.isNotEmpty) { if (envGroup == null && orphans.isNotEmpty) {
final repaired = await CloudStorageModule.repairOrphan(api, orphans.first); final repaired = await CloudStorageModule.repairOrphan(
api,
orphans.first,
);
if (repaired != null) { if (repaired != null) {
envGroup = repaired; envGroup = repaired;
await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id); await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id);
@@ -161,14 +167,23 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
void _deleteOrLeave(int accountId, CachedChat chat) async { void _deleteOrLeave(int accountId, CachedChat chat) async {
final isAdmin = chat.owner == accountId || chat.admins.contains(accountId); final isAdmin = chat.owner == accountId || chat.admins.contains(accountId);
if (isAdmin) { if (isAdmin) {
await ChatsModule.deleteChat(api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true); await ChatsModule.deleteChat(
api,
chatId: chat.id,
lastEventTime: chat.lastEventTime,
forAll: true,
);
} else { } else {
await ChatsModule.leaveChat(api, chatId: chat.id); await ChatsModule.leaveChat(api, chatId: chat.id);
} }
} }
Future<void> _loadFiles(int accountId, int chatId) async { Future<void> _loadFiles(int accountId, int chatId) async {
final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId); final files = await CloudStorageModule.fetchFiles(
messagesModule,
accountId,
chatId,
);
if (!mounted) return; if (!mounted) return;
setState(() => _files = files.reversed.toList()); setState(() => _files = files.reversed.toList());
} }
@@ -179,8 +194,11 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
_animateNewCard = true; _animateNewCard = true;
}); });
if (_pageController.hasClients) { if (_pageController.hasClients) {
_pageController.animateToPage(0, _pageController.animateToPage(
duration: const Duration(milliseconds: 350), curve: Curves.easeOut); 0,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
);
} }
Future.delayed(const Duration(milliseconds: 800), () { Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) setState(() => _animateNewCard = false); if (mounted) setState(() => _animateNewCard = false);
@@ -248,7 +266,10 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
final ok = await messagesModule.sendFileMessage(chatId, id); final ok = await messagesModule.sendFileMessage(chatId, id);
if (!ok) return false; if (!ok) return false;
final newest = await CloudStorageModule.fetchLatestFile( final newest = await CloudStorageModule.fetchLatestFile(
messagesModule, accountId, chatId, expectedFileId: id, messagesModule,
accountId,
chatId,
expectedFileId: id,
); );
if (mounted) { if (mounted) {
if (newest != null) { if (newest != null) {
@@ -316,23 +337,45 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
Text( Text(
'Среда для облачного хранилища не настроена', 'Среда для облачного хранилища не настроена',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), style: TextStyle(
color: cs.onSurface,
fontSize: 17,
fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)), Text(
'Начнем? Это быстро.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton( FilledButton(
onPressed: _isCreatingEnv ? null : _setupEnv, onPressed: _isCreatingEnv ? null : _setupEnv,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), padding: const EdgeInsets.symmetric(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), horizontal: 32,
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
), ),
child: _isCreatingEnv child: _isCreatingEnv
? SizedBox( ? SizedBox(
width: 18, height: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
) )
: const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), : const Text(
'Начать',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
), ),
], ],
), ),
@@ -382,7 +425,11 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
); );
} }
Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) { Widget _buildUploadingCenterHint(
ColorScheme cs,
double t,
double availableWidth,
) {
final cardSide = availableWidth * _cardViewportFraction; final cardSide = availableWidth * _cardViewportFraction;
return Center( return Center(
child: Opacity( child: Opacity(
@@ -412,7 +459,9 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
); );
if (i == 0 && _animateNewCard) { if (i == 0 && _animateNewCard) {
return _FadeScaleEntry( return _FadeScaleEntry(
key: ValueKey('${_files[0].messageId}_${_files[0].time}'), key: ValueKey(
'${_files[0].messageId}_${_files[0].time}',
),
child: padded, child: padded,
); );
} }
@@ -448,8 +497,10 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Загрузка ${(progress * 100).toStringAsFixed(0)}%', 'Загрузка ${(progress * 100).toStringAsFixed(0)}%',
style: style: TextStyle(
TextStyle(color: cs.onSurfaceVariant, fontSize: 13), color: cs.onSurfaceVariant,
fontSize: 13,
),
), ),
], ],
), ),
@@ -556,11 +607,11 @@ class _UploadModeController {
final AnimationController anim; final AnimationController anim;
_UploadModeController(TickerProvider vsync) _UploadModeController(TickerProvider vsync)
: anim = AnimationController( : anim = AnimationController(
vsync: vsync, vsync: vsync,
duration: _openDuration, duration: _openDuration,
reverseDuration: _closeDuration, reverseDuration: _closeDuration,
); );
bool get isOpen => anim.value > 0; bool get isOpen => anim.value > 0;
@@ -706,10 +757,7 @@ class _DragDownHintState extends State<_DragDownHint>
if (phase > _activeFraction) return (dy: 0, opacity: 0); if (phase > _activeFraction) return (dy: 0, opacity: 0);
final local = phase / _activeFraction; final local = phase / _activeFraction;
final eased = Curves.easeOutCubic.transform(local); final eased = Curves.easeOutCubic.transform(local);
return ( return (dy: _startY + eased * _travel, opacity: (1 - local) * _peakOpacity);
dy: _startY + eased * _travel,
opacity: (1 - local) * _peakOpacity,
);
} }
} }
@@ -738,9 +786,15 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_c = AnimationController(vsync: this, duration: const Duration(milliseconds: 550)); _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 550),
);
_scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut); _scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut);
_opacity = CurvedAnimation(parent: _c, curve: const Interval(0, 0.4, curve: Curves.easeIn)); _opacity = CurvedAnimation(
parent: _c,
curve: const Interval(0, 0.4, curve: Curves.easeIn),
);
_c.forward(); _c.forward();
} }
@@ -789,7 +843,7 @@ class _CloudFileCard extends StatelessWidget {
final d = DateTime.fromMillisecondsSinceEpoch(millis); final d = DateTime.fromMillisecondsSinceEpoch(millis);
final now = DateTime.now(); final now = DateTime.now();
if (d.year == now.year && d.month == now.month && d.day == now.day) { if (d.year == now.year && d.month == now.month && d.day == now.day) {
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; return formatClock(d);
} }
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}'; return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}';
} }
@@ -805,7 +859,10 @@ class _CloudFileCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerLow, color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5), border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -834,7 +891,10 @@ class _CloudFileCard extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatTime(file.time), _formatTime(file.time),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 10,
),
), ),
], ],
), ),
@@ -889,18 +949,23 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
chatId: f.chatId, chatId: f.chatId,
messageId: f.messageId, messageId: f.messageId,
); );
if (mounted) setState(() { _link = result; _loading = false; }); if (mounted) {
setState(() {
_link = result;
_loading = false;
});
}
} }
static String _formatSize(int? bytes) { static String _formatSize(int? bytes) {
if (bytes == null) return ''; if (bytes == null) return '';
if (bytes < 1024) return '$bytes Б'; return formatBytes(bytes);
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
} }
static String _formatExpiry(int expiresMs) { static String _formatExpiry(int expiresMs) {
final remaining = DateTime.fromMillisecondsSinceEpoch(expiresMs).difference(DateTime.now()); final remaining = DateTime.fromMillisecondsSinceEpoch(
expiresMs,
).difference(DateTime.now());
if (remaining.isNegative) return 'истекла'; if (remaining.isNegative) return 'истекла';
final h = remaining.inHours; final h = remaining.inHours;
final m = remaining.inMinutes % 60; final m = remaining.inMinutes % 60;
@@ -913,7 +978,8 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final f = widget.file; final f = widget.file;
final isExpired = _link == null || final isExpired =
_link == null ||
_link!.expires <= DateTime.now().millisecondsSinceEpoch; _link!.expires <= DateTime.now().millisecondsSinceEpoch;
return Container( return Container(
@@ -922,25 +988,25 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
), ),
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
24, 16, 24, 24,
16,
24,
MediaQuery.of(context).viewInsets.bottom + 32, MediaQuery.of(context).viewInsets.bottom + 32,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
child: Container( const SizedBox(height: 20),
width: 36, height: 4, Text(
decoration: BoxDecoration( f.name,
color: cs.outlineVariant, style: TextStyle(
borderRadius: BorderRadius.circular(2), color: cs.onSurface,
), fontSize: 15,
fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 20),
Text(f.name,
style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 12), const SizedBox(height: 12),
_InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? ''), _InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? ''),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -952,16 +1018,27 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
children: [ children: [
Expanded( Expanded(
child: isExpired child: isExpired
? Text('Ссылки пока нет. Создайте.', ? Text(
style: TextStyle(color: cs.error, fontSize: 13)) 'Ссылки пока нет. Создайте.',
: Text('Ссылка истечет ${_formatExpiry(_link!.expires)}', style: TextStyle(color: cs.error, fontSize: 13),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), )
: Text(
'Ссылка истечет ${_formatExpiry(_link!.expires)}',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_loading _loading
? SizedBox( ? SizedBox(
width: 20, height: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary), height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.primary,
),
) )
: IconButton( : IconButton(
icon: Icon( icon: Icon(
@@ -974,8 +1051,13 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
onPressed: isExpired onPressed: isExpired
? _generateLink ? _generateLink
: () { : () {
Clipboard.setData(ClipboardData(text: _link!.url)); Clipboard.setData(
showCustomNotification(context, 'Ссылка скопирована'); ClipboardData(text: _link!.url),
);
showCustomNotification(
context,
'Ссылка скопирована',
);
}, },
), ),
], ],
@@ -996,11 +1078,18 @@ class _InfoRow extends StatelessWidget {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return Row( return Row(
children: [ children: [
Text('$label: ', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), Text(
'$label: ',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
Expanded( Expanded(
child: Text( child: Text(
value, value,
style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500), style: TextStyle(
color: cs.onSurface,
fontSize: 13,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -1053,24 +1142,25 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
), ),
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
24, 16, 24, 24,
16,
24,
MediaQuery.of(context).viewInsets.bottom + 32, MediaQuery.of(context).viewInsets.bottom + 32,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
child: Container( const SizedBox(height: 20),
width: 36, height: 4, Text(
decoration: BoxDecoration( 'Отправить по ID',
color: cs.outlineVariant, borderRadius: BorderRadius.circular(2), style: TextStyle(
), color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 20),
Text('Отправить по ID',
style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: _controller, controller: _controller,
@@ -1087,7 +1177,10 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -1095,14 +1188,23 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
onPressed: _sending ? null : _submit, onPressed: _sending ? null : _submit,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: _sending child: _sending
? SizedBox( ? SizedBox(
width: 18, height: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
) )
: const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)), : const Text(
'Отправить',
style: TextStyle(fontWeight: FontWeight.w600),
),
), ),
], ],
), ),
@@ -10,10 +10,12 @@ import '../../../core/config/app_media_cache.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/protocol/packet.dart'; import '../../../core/protocol/packet.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/media_cache.dart'; import '../../../core/utils/media_cache.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/login_success_screen.dart'; import '../../widgets/login_success_screen.dart';
import '../calls/call_screen.dart'; import '../calls/call_screen.dart';
@@ -53,7 +55,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
_clearingCache = false; _clearingCache = false;
_cacheSize = 0; _cacheSize = 0;
}); });
showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})'); showCustomNotification(context, 'Кэш очищен (${formatBytes(freed)})');
} }
void _pickCacheLimit() { void _pickCacheLimit() {
@@ -61,9 +63,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -106,16 +106,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
} }
String _limitLabel(int bytes) => String _limitLabel(int bytes) =>
bytes <= 0 ? 'Без лимита' : _formatBytes(bytes); bytes <= 0 ? 'Без лимита' : formatBytes(bytes);
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes Б';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ';
}
@override @override
void dispose() { void dispose() {
@@ -133,7 +124,10 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
_errors.clear(); _errors.clear();
}); });
Future<void> tryProbe(String label, Future<dynamic> Function() probe) async { Future<void> tryProbe(
String label,
Future<dynamic> Function() probe,
) async {
try { try {
final res = await probe(); final res = await probe();
logger.i('debug-search $label($id): $res'); logger.i('debug-search $label($id): $res');
@@ -147,11 +141,15 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
await Future.wait([ await Future.wait([
tryProbe('contactInfo', () async { tryProbe('contactInfo', () async {
final p = await api.sendRequest(Opcode.contactInfo, {'contactIds': [id]}); final p = await api.sendRequest(Opcode.contactInfo, {
'contactIds': [id],
});
return p.payload; return p.payload;
}), }),
tryProbe('chatInfo', () async { tryProbe('chatInfo', () async {
final p = await api.sendRequest(Opcode.chatInfo, {'chatIds': [id]}); final p = await api.sendRequest(Opcode.chatInfo, {
'chatIds': [id],
});
return p.payload; return p.payload;
}), }),
tryProbe('publicSearch', () => ChatsModule.searchById(api, id)), tryProbe('publicSearch', () => ChatsModule.searchById(api, id)),
@@ -704,7 +702,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
Text( Text(
_clearingCache _clearingCache
? 'Очистка…' ? 'Очистка…'
: 'Занято: ${_formatBytes(_cacheSize)}', : 'Занято: ${formatBytes(_cacheSize)}',
style: TextStyle( style: TextStyle(
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
fontSize: 13, fontSize: 13,
@@ -958,7 +956,10 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( child: Text(
'Ничего не найдено', 'Ничего не найдено',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
), ),
), ),
for (final hit in _hits) ...[ for (final hit in _hits) ...[
@@ -1152,7 +1153,11 @@ class _SearchResultCard extends StatelessWidget {
), ),
IconButton( IconButton(
tooltip: 'Скопировать id', tooltip: 'Скопировать id',
icon: Icon(Symbols.content_copy, size: 18, color: cs.onSurfaceVariant), icon: Icon(
Symbols.content_copy,
size: 18,
color: cs.onSurfaceVariant,
),
onPressed: () async { onPressed: () async {
await Clipboard.setData(ClipboardData(text: hit.id.toString())); await Clipboard.setData(ClipboardData(text: hit.id.toString()));
if (context.mounted) { if (context.mounted) {
@@ -1292,10 +1297,7 @@ class _ErrorChip extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
'$label: $message', '$label: $message',
style: TextStyle( style: TextStyle(color: cs.onErrorContainer, fontSize: 12),
color: cs.onErrorContainer,
fontSize: 12,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -1347,4 +1349,4 @@ class _DebugCallButton extends StatelessWidget {
), ),
); );
} }
} }
@@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/format.dart';
import '../../../main.dart' show accountModule; import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo; import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
import 'web_qr_scan_screen.dart'; import 'web_qr_scan_screen.dart';
class DevicesScreen extends StatefulWidget { class DevicesScreen extends StatefulWidget {
@@ -125,16 +127,7 @@ class _DevicesScreenState extends State<DevicesScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Center( const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'Вход по QR', 'Вход по QR',
@@ -158,8 +151,7 @@ class _DevicesScreenState extends State<DevicesScreen>
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () => onPressed: () => Navigator.of(sheetContext).pop(false),
Navigator.of(sheetContext).pop(false),
child: Text( child: Text(
'Отмена', 'Отмена',
style: TextStyle(color: cs.onSurface), style: TextStyle(color: cs.onSurface),
@@ -169,8 +161,7 @@ class _DevicesScreenState extends State<DevicesScreen>
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: () => onPressed: () => Navigator.of(sheetContext).pop(true),
Navigator.of(sheetContext).pop(true),
child: const Text('Войти'), child: const Text('Войти'),
), ),
), ),
@@ -186,7 +177,8 @@ class _DevicesScreenState extends State<DevicesScreen>
} }
Future<void> _startWebQrAuth() async { Future<void> _startWebQrAuth() async {
final canScan = !kIsWeb && final canScan =
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android || (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS); defaultTargetPlatform == TargetPlatform.iOS);
@@ -310,29 +302,14 @@ class _DevicesScreenState extends State<DevicesScreen>
if (now.year == date.year && if (now.year == date.year &&
now.month == date.month && now.month == date.month &&
now.day == date.day) { now.day == date.day) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; return formatClock(date);
} }
final months = [
'янв.',
'февр.',
'мар.',
'апр.',
'мая',
'июня',
'июля',
'авг.',
'сент.',
'окт.',
'нояб.',
'дек.',
];
if (now.year == date.year) { if (now.year == date.year) {
return '${date.day} ${months[date.month - 1]}'; return '${date.day} ${kRuMonthsShort[date.month - 1]}';
} }
return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}'; return formatDateNumeric(date);
} }
@override @override
@@ -7,8 +7,6 @@ import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../../main.dart' show accountModule, fileUploader, KometApp;
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
const int _maxAvatarBytes = 8 * 1024 * 1024;
class EditProfileScreen extends StatefulWidget { class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key}); const EditProfileScreen({super.key});
@@ -62,7 +60,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
try { try {
final newProfile = await accountModule.updateProfileName( final newProfile = await accountModule.updateProfileName(
firstName, firstName,
_lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), _lastNameController.text.trim().isEmpty
? null
: _lastNameController.text.trim(),
); );
_avatarUrl = newProfile.baseUrl; _avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId; _photoId = newProfile.photoId;
@@ -92,8 +92,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
return; return;
} }
if (bytes.length > _maxAvatarBytes) { if (bytes.length > kMaxAvatarBytes) {
if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); if (mounted) {
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
}
return; return;
} }
if (!mounted) return; if (!mounted) return;
@@ -183,7 +185,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
) )
: Text( : Text(
l10n?.editProfileSave ?? 'Save', l10n?.editProfileSave ?? 'Save',
style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), style: TextStyle(
color: cs.primary,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],
@@ -214,7 +219,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
_firstNameController.text.isNotEmpty _firstNameController.text.isNotEmpty
? _firstNameController.text[0].toUpperCase() ? _firstNameController.text[0]
.toUpperCase()
: '?', : '?',
style: TextStyle( style: TextStyle(
color: cs.onPrimaryContainer, color: cs.onPrimaryContainer,
@@ -234,7 +240,11 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), icon: Icon(
Symbols.camera_alt,
color: cs.onPrimary,
size: 20,
),
onPressed: _changeAvatar, onPressed: _changeAvatar,
), ),
), ),
@@ -274,13 +284,21 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
); );
} }
Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { Widget _buildTextField(
String label,
TextEditingController controller,
ColorScheme cs, {
bool enabled = true,
}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 4, bottom: 6), padding: const EdgeInsets.only(left: 4, bottom: 6),
child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), child: Text(
label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
), ),
TextField( TextField(
controller: controller, controller: controller,
@@ -292,10 +310,13 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
), ),
), ),
], ],
); );
} }
} }
+47 -35
View File
@@ -5,6 +5,7 @@ import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart';
class InfoScreen extends StatefulWidget { class InfoScreen extends StatefulWidget {
const InfoScreen({super.key}); const InfoScreen({super.key});
@@ -65,13 +66,13 @@ class _InfoScreenState extends State<InfoScreen> {
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _info == null : _info == null
? Center( ? Center(
child: Text( child: Text(
'No data', 'No data',
style: TextStyle(color: cs.onSurfaceVariant), style: TextStyle(color: cs.onSurfaceVariant),
), ),
) )
: _buildContent(cs, l10n!), : _buildContent(cs, l10n!),
); );
} }
@@ -114,29 +115,49 @@ class _InfoScreenState extends State<InfoScreen> {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildSectionTitle(l10n.infoAccountSection, cs), SectionHeader(l10n.infoAccountSection),
...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), ...accountKeys.entries.map(
(e) =>
_buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs),
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSectionTitle(l10n.infoServerSection, cs), SectionHeader(l10n.infoServerSection),
...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), ...serverKeys.entries.map(
(e) => _buildRow(
e.key,
e.value,
_formatValue(server?[e.key], e.key),
cs,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSectionTitle(l10n.infoYMapSection, cs), SectionHeader(l10n.infoYMapSection),
_buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs), _buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs),
_buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs), _buildRow(
_buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs), 'geocoder',
l10n.infoGeocoder,
yMap?['geocoder']?.toString() ?? '-',
cs,
),
_buildRow(
'static',
l10n.infoStatic,
yMap?['static']?.toString() ?? '-',
cs,
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSectionTitle(l10n.infoFileUploadTypes, cs), SectionHeader(l10n.infoFileUploadTypes),
_buildListRow(server?['file-upload-unsupported-types'] as List?, cs), _buildListRow(server?['file-upload-unsupported-types'] as List?, cs),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSectionTitle(l10n.infoWhiteListLinks, cs), SectionHeader(l10n.infoWhiteListLinks),
_buildListRow(server?['white-list-links'] as List?, cs), _buildListRow(server?['white-list-links'] as List?, cs),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSectionTitle(l10n.infoUserSection, cs), SectionHeader(l10n.infoUserSection),
if (user != null) if (user != null)
...user.entries ...user.entries
.where((e) => e.value != null) .where((e) => e.value != null)
@@ -147,21 +168,6 @@ class _InfoScreenState extends State<InfoScreen> {
); );
} }
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) { Widget _buildRow(String key, String label, String value, ColorScheme cs) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 1), margin: const EdgeInsets.only(bottom: 1),
@@ -224,7 +230,10 @@ class _InfoScreenState extends State<InfoScreen> {
children: items children: items
.map( .map(
(item) => Container( (item) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerHighest, color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -250,7 +259,10 @@ class _InfoScreenState extends State<InfoScreen> {
if (key == 'edit-timeout' && value is int && value > 0) { if (key == 'edit-timeout' && value is int && value > 0) {
final weeks = value ~/ 604800; final weeks = value ~/ 604800;
final days = (value % 604800) ~/ 86400; final days = (value % 604800) ~/ 86400;
if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); if (weeks > 0) {
return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'
.trim();
}
final h = value ~/ 3600; final h = value ~/ 3600;
final m = (value % 3600) ~/ 60; final m = (value % 3600) ~/ 60;
if (h > 0) return '${h}h ${m}m'; if (h > 0) return '${h}h ${m}m';
@@ -279,4 +291,4 @@ class _InfoScreenState extends State<InfoScreen> {
if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн'; if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн';
return 'дн'; return 'дн';
} }
} }
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart'; import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../widgets/section_header.dart';
import '../../widgets/sheet_helpers.dart';
class NotificationsScreen extends StatefulWidget { class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key}); const NotificationsScreen({super.key});
@@ -29,9 +32,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
final picked = await showModalBottomSheet<String>( final picked = await showModalBottomSheet<String>(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) { builder: (context) {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -67,10 +68,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
), ),
title: Text( title: Text(
s, s,
style: TextStyle( style: TextStyle(color: cs.onSurface, fontSize: 16),
color: cs.onSurface,
fontSize: 16,
),
), ),
), ),
], ],
@@ -90,17 +88,18 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
return Scaffold( return Scaffold(
backgroundColor: cs.surface, backgroundColor: cs.surface,
appBar: AppBarM3E( appBar: AppBarM3E(titleText: 'Уведомления', backgroundColor: cs.surface),
titleText: 'Уведомления',
backgroundColor: cs.surface,
),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: ListView( child: ListView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [ children: [
_sectionHeader(cs, 'FKM'), const SectionHeader(
'FKM',
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
fontSize: 14,
),
_card(cs, [ _card(cs, [
_toggleRow( _toggleRow(
cs, cs,
@@ -113,7 +112,11 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
_sectionHeader(cs, 'Настройки уведомлений'), const SectionHeader(
'Настройки уведомлений',
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
fontSize: 14,
),
_card(cs, [ _card(cs, [
_toggleRow( _toggleRow(
cs, cs,
@@ -140,7 +143,11 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
_sectionHeader(cs, 'Звук'), const SectionHeader(
'Звук',
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
fontSize: 14,
),
_card(cs, [ _card(cs, [
_tappableRow( _tappableRow(
cs, cs,
@@ -156,21 +163,6 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
); );
} }
Widget _sectionHeader(ColorScheme cs, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
);
}
Widget _card(ColorScheme cs, List<Widget> children) { Widget _card(ColorScheme cs, List<Widget> children) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -275,10 +267,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
), ),
Text( Text(
trailingText, trailingText,
style: TextStyle( style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
color: cs.onSurfaceVariant,
fontSize: 14,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Icon(Symbols.chevron_right, color: cs.outline, size: 20), Icon(Symbols.chevron_right, color: cs.outline, size: 20),
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show accountModule; import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show TwoFactorDetails; import '../../../backend/modules/account.dart' show TwoFactorDetails;
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
class PasswordEntryScreen extends StatefulWidget { class PasswordEntryScreen extends StatefulWidget {
@@ -270,36 +271,22 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
); );
} }
void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { Future<void> _showRemoveConfirmation(
showDialog( BuildContext context,
context: context, ColorScheme cs,
builder: (context) => AlertDialog( ) async {
backgroundColor: cs.surfaceContainerHigh, final confirmed = await showConfirmDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), context,
title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), title: 'Удалить пароль?',
content: Text( message:
'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.',
style: TextStyle(color: cs.onSurfaceVariant), confirmLabel: 'Удалить',
), destructive: true,
actions: [ );
TextButton( if (!confirmed || !context.mounted) return;
onPressed: () => Navigator.pop(context), Navigator.push(
child: Text('Отмена', style: TextStyle(color: cs.primary)), context,
), MaterialPageRoute(builder: (context) => const TwoFactorRemoveScreen()),
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TwoFactorRemoveScreen(),
),
);
},
child: Text('Удалить', style: TextStyle(color: cs.error)),
),
],
),
); );
} }
} }
@@ -813,10 +800,7 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
style: TextStyle(color: cs.onErrorContainer), style: TextStyle(color: cs.onErrorContainer),
), ),
), ),
_PasswordField( _PasswordField(controller: _passwordController, hintText: 'Пароль'),
controller: _passwordController,
hintText: 'Пароль',
),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -1414,10 +1398,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
style: TextStyle(color: cs.onErrorContainer), style: TextStyle(color: cs.onErrorContainer),
), ),
), ),
_PasswordField( _PasswordField(controller: _passwordController, hintText: 'Пароль'),
controller: _passwordController,
hintText: 'Пароль',
),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -1457,10 +1438,7 @@ class _PasswordField extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final String hintText; final String hintText;
const _PasswordField({ const _PasswordField({required this.controller, required this.hintText});
required this.controller,
required this.hintText,
});
@override @override
State<_PasswordField> createState() => _PasswordFieldState(); State<_PasswordField> createState() => _PasswordFieldState();
@@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart';
import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_cache_extent.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../widgets/confirm_dialog.dart';
class PerformanceScreen extends StatefulWidget { class PerformanceScreen extends StatefulWidget {
const PerformanceScreen({super.key}); const PerformanceScreen({super.key});
@@ -25,7 +26,8 @@ class _PerformanceScreenState extends State<PerformanceScreen> {
} }
bool _isInSafeZone(double v) => bool _isInSafeZone(double v) =>
v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold; v >= AppCacheExtent.lowWarnThreshold &&
v < AppCacheExtent.highWarnThreshold;
void _onChanged(double v) { void _onChanged(double v) {
setState(() { setState(() {
@@ -41,8 +43,7 @@ class _PerformanceScreenState extends State<PerformanceScreen> {
if (inLow && !_lowWarnDismissed) { if (inLow && !_lowWarnDismissed) {
final ok = await _showWarning( final ok = await _showWarning(
text: text: 'Производительность приложения может снизиться, вы уверены?',
'Производительность приложения может снизиться, вы уверены?',
); );
if (ok) { if (ok) {
_lowWarnDismissed = true; _lowWarnDismissed = true;
@@ -73,37 +74,13 @@ class _PerformanceScreenState extends State<PerformanceScreen> {
await AppCacheExtent.save(v); await AppCacheExtent.save(v);
} }
Future<bool> _showWarning({required String text}) async { Future<bool> _showWarning({required String text}) {
final cs = Theme.of(context).colorScheme; return showConfirmDialog(
final res = await showDialog<bool>( context,
context: context, message: text,
builder: (context) { confirmLabel: 'Да',
return AlertDialog( cancelLabel: 'Нет',
backgroundColor: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
content: Text(
text,
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
'Нет',
style: TextStyle(color: cs.onSurfaceVariant),
),
),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Да'),
),
],
);
},
); );
return res ?? false;
} }
@override @override
@@ -5,7 +5,9 @@ import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' import '../../../backend/modules/account.dart'
show PrivacyConfig, BlockedContact; show PrivacyConfig, BlockedContact;
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
import 'password_entry_screen.dart'; import 'password_entry_screen.dart';
class SecurityScreen extends StatefulWidget { class SecurityScreen extends StatefulWidget {
@@ -539,14 +541,7 @@ class _SecurityScreenState extends State<SecurityScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
Container( const SheetGrabber(margin: EdgeInsets.zero),
width: 36,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
title, title,
@@ -598,37 +593,20 @@ class _SecurityScreenState extends State<SecurityScreen>
); );
} }
void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { Future<void> _showHiddenStatusSheet(
BuildContext context,
ColorScheme cs,
) async {
final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS';
if (currentValue == 'NONE') { if (currentValue == 'NONE') {
showDialog( final confirmed = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( title: 'Вы уверены?',
backgroundColor: cs.surfaceContainerHigh, message: 'Вы не сможете видеть статусы посещения других пользователей.',
shape: RoundedRectangleBorder( confirmLabel: 'Да',
borderRadius: BorderRadius.circular(20),
),
title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)),
content: Text(
'Вы не сможете видеть статусы посещения других пользователей.',
style: TextStyle(color: cs.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Отмена', style: TextStyle(color: cs.primary)),
),
TextButton(
onPressed: () {
Navigator.pop(context);
_updateSetting('HIDDEN', false);
},
child: Text('Да', style: TextStyle(color: cs.primary)),
),
],
),
); );
if (confirmed) _updateSetting('HIDDEN', false);
return; return;
} }
@@ -644,14 +622,7 @@ class _SecurityScreenState extends State<SecurityScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
Container( const SheetGrabber(margin: EdgeInsets.zero),
width: 36,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Видеть статус «в сети»', 'Видеть статус «в сети»',
@@ -683,32 +654,17 @@ class _SecurityScreenState extends State<SecurityScreen>
); );
} }
void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { Future<void> _showHiddenStatusConfirmDialog(
showDialog( BuildContext context,
context: context, ColorScheme cs,
builder: (context) => AlertDialog( ) async {
backgroundColor: cs.surfaceContainerHigh, final confirmed = await showConfirmDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), context,
title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), title: 'Вы уверены?',
content: Text( message: 'Вы не сможете видеть статусы посещения других пользователей.',
'Вы не сможете видеть статусы посещения других пользователей.', confirmLabel: 'Да',
style: TextStyle(color: cs.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Отмена', style: TextStyle(color: cs.primary)),
),
TextButton(
onPressed: () {
Navigator.pop(context);
_updateSetting('HIDDEN', true);
},
child: Text('Да', style: TextStyle(color: cs.primary)),
),
],
),
); );
if (confirmed) _updateSetting('HIDDEN', true);
} }
Widget _buildOptionSheetItem( Widget _buildOptionSheetItem(
+17 -49
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -12,6 +11,8 @@ import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/info_action_sheet.dart'; import '../../widgets/info_action_sheet.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/sheet_helpers.dart';
import '../auth/login_screen.dart'; import '../auth/login_screen.dart';
import '../auth/proxy_settings_sheet.dart'; import '../auth/proxy_settings_sheet.dart';
import 'cloud_storage_screen.dart'; import 'cloud_storage_screen.dart';
@@ -144,9 +145,7 @@ class _SettingsTabState extends State<SettingsTab> {
final confirmed = await showModalBottomSheet<bool>( final confirmed = await showModalBottomSheet<bool>(
context: context, context: context,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) { builder: (ctx) {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -245,11 +244,14 @@ class _SettingsTabState extends State<SettingsTab> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _buildSection( child: _buildSection(
context, context,
cs, cs,
items: [ items: [
const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), const _SettingsItem(
icon: Symbols.badge,
label: 'Цифровой ID',
),
const _SettingsItem( const _SettingsItem(
icon: Symbols.language, icon: Symbols.language,
label: 'Войти в Сферум', label: 'Войти в Сферум',
@@ -344,15 +346,9 @@ child: _buildSection(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh, backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder( shape: kSheetShape,
borderRadius: BorderRadius.vertical(
top: Radius.circular(24),
),
),
builder: (_) { builder: (_) {
return SafeArea( return SafeArea(child: const ProxySettingsSheet());
child: const ProxySettingsSheet(),
);
}, },
); );
}, },
@@ -407,10 +403,7 @@ child: _buildSection(
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
heightFactor: animation.value.clamp(0.0, 1.0), heightFactor: animation.value.clamp(0.0, 1.0),
child: FadeTransition( child: FadeTransition(opacity: animation, child: child),
opacity: animation,
child: child,
),
), ),
); );
}, },
@@ -418,10 +411,7 @@ child: _buildSection(
return Stack( return Stack(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: <Widget>[ children: <Widget>[...previousChildren, ?currentChild],
...previousChildren,
?currentChild,
],
); );
}, },
child: _debugMenuVisible child: _debugMenuVisible
@@ -557,18 +547,11 @@ child: _buildSection(
width: 2.5, width: 2.5,
), ),
), ),
child: ClipOval( child: KometAvatar(
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty name: name,
? CachedNetworkImage( imageUrl: _profile?.baseUrl,
imageUrl: _profile!.baseUrl!, size: 88,
fit: BoxFit.cover, fontSize: 32,
memCacheWidth: 240,
memCacheHeight: 240,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, name),
)
: _buildPlaceholderAvatar(cs, name),
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -614,21 +597,6 @@ child: _buildSection(
); );
} }
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
return Container(
color: cs.primaryContainer,
alignment: Alignment.center,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildSection( Widget _buildSection(
BuildContext context, BuildContext context,
ColorScheme cs, { ColorScheme cs, {
+16 -16
View File
@@ -14,6 +14,7 @@ import '../../../core/storage/token_storage.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/info_action_sheet.dart'; import '../../widgets/info_action_sheet.dart';
import '../../widgets/section_header.dart';
import '../auth/login_screen.dart'; import '../auth/login_screen.dart';
enum SpoofingMethod { partial, full } enum SpoofingMethod { partial, full }
@@ -621,19 +622,6 @@ class _SpoofScreenState extends State<SpoofScreen> {
); );
} }
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildMainDataCard() { Widget _buildMainDataCard() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Card( return Card(
@@ -642,7 +630,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader(context, l10n.spoofMainSectionTitle), SectionHeader(
l10n.spoofMainSectionTitle,
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
fontSize: 22,
),
TextField( TextField(
controller: _deviceNameController, controller: _deviceNameController,
decoration: _inputDecoration( decoration: _inputDecoration(
@@ -672,7 +664,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader(context, l10n.spoofRegionalSectionTitle), SectionHeader(
l10n.spoofRegionalSectionTitle,
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
fontSize: 22,
),
TextField( TextField(
controller: _screenController, controller: _screenController,
decoration: _inputDecoration( decoration: _inputDecoration(
@@ -721,7 +717,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle), SectionHeader(
l10n.spoofIdentifiersSectionTitle,
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
fontSize: 22,
),
_buildDescriptionTile( _buildDescriptionTile(
icon: Icons.info_outline, icon: Icons.info_outline,
color: Theme.of(context).colorScheme.tertiary, color: Theme.of(context).colorScheme.tertiary,
@@ -1,6 +1,5 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -8,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart'; import '../../core/storage/token_storage.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
import 'komet_avatar.dart';
class AccountSwitcherController extends ChangeNotifier { class AccountSwitcherController extends ChangeNotifier {
Offset? pointer; Offset? pointer;
@@ -301,9 +301,7 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer>
highlighted: _hoveredIndex == i, highlighted: _hoveredIndex == i,
active: _accounts[i].id == _activeId, active: _accounts[i].id == _activeId,
), ),
_AddAccountRow( _AddAccountRow(highlighted: _hoveredIndex == _accounts.length),
highlighted: _hoveredIndex == _accounts.length,
),
], ],
), ),
), ),
@@ -363,17 +361,15 @@ class _AccountRow extends StatelessWidget {
) )
: null, : null,
), ),
child: ClipOval( child: KometAvatar(
child: profile.baseUrl != null && profile.baseUrl!.isNotEmpty name: fullName,
? CachedNetworkImage( imageUrl: profile.baseUrl,
imageUrl: profile.baseUrl!, size: 36,
fit: BoxFit.cover, backgroundColor: highlighted
memCacheWidth: 96, ? cs.primaryContainer
memCacheHeight: 96, : cs.surfaceContainerHighest,
errorWidget: (_, __, ___) => foregroundColor: cs.onSurface,
_initialAvatar(cs, fullName, highlighted), fontSize: 16,
)
: _initialAvatar(cs, fullName, highlighted),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -418,21 +414,6 @@ class _AccountRow extends StatelessWidget {
), ),
); );
} }
Widget _initialAvatar(ColorScheme cs, String name, bool highlighted) {
return Container(
color: highlighted ? cs.primaryContainer : cs.surfaceContainerHighest,
alignment: Alignment.center,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
);
}
} }
class _AddAccountRow extends StatelessWidget { class _AddAccountRow extends StatelessWidget {
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/sheet_helpers.dart';
import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart';
const List<PillNavItem> _navItems = [ const List<PillNavItem> _navItems = [
@@ -126,7 +128,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: [
_buildHandle(cs), const SheetGrabber(),
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
@@ -178,18 +180,6 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
static const double _barHeight = SlidingPillNav.height + _pillMargin; static const double _barHeight = SlidingPillNav.height + _pillMargin;
static const Duration _navAnim = Duration(milliseconds: 300); static const Duration _navAnim = Duration(milliseconds: 300);
Widget _buildHandle(ColorScheme cs) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 10),
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
);
}
Widget _buildPages( Widget _buildPages(
ScrollController scrollController, ScrollController scrollController,
ColorScheme cs, ColorScheme cs,
@@ -597,7 +587,7 @@ class _GalleryTileState extends State<_GalleryTile> {
), ),
if (item.duration != null) if (item.duration != null)
Text( Text(
_formatDuration(item.duration!), formatDurationMmSs(item.duration!),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 11, fontSize: 11,
@@ -617,12 +607,6 @@ class _GalleryTileState extends State<_GalleryTile> {
), ),
); );
} }
String _formatDuration(Duration d) {
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
} }
class _SelectionCheck extends StatelessWidget { class _SelectionCheck extends StatelessWidget {
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
/// Shared confirmation dialog. Returns true if confirmed, false otherwise.
Future<bool> showConfirmDialog(
BuildContext context, {
String? title,
required String message,
String confirmLabel = 'OK',
String cancelLabel = 'Отмена',
bool destructive = false,
}) async {
final cs = Theme.of(context).colorScheme;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: title == null
? null
: Text(title, style: TextStyle(color: cs.onSurface)),
content: Text(
message,
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel, style: TextStyle(color: cs.onSurfaceVariant)),
),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(true),
style: destructive
? FilledButton.styleFrom(
backgroundColor: cs.errorContainer,
foregroundColor: cs.onErrorContainer,
)
: null,
child: Text(confirmLabel),
),
],
),
);
return result ?? false;
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
/// Circular avatar: shows [imageUrl] when available, otherwise the first letter
/// of [name] on a colored background. Falls back to the letter on image error.
class KometAvatar extends StatelessWidget {
final String name;
final String? imageUrl;
final double size;
final Color? backgroundColor;
final Color? foregroundColor;
final double? fontSize;
const KometAvatar({
super.key,
required this.name,
required this.size,
this.imageUrl,
this.backgroundColor,
this.foregroundColor,
this.fontSize,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final bg = backgroundColor ?? cs.primaryContainer;
final fg = foregroundColor ?? cs.onPrimaryContainer;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
final placeholder = Center(
child: Text(
letter,
style: TextStyle(
color: fg,
fontSize: fontSize ?? size * 0.4,
fontWeight: FontWeight.bold,
),
),
);
final url = imageUrl;
final cache = (size * 3).round();
return Container(
width: size,
height: size,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: (url != null && url.isNotEmpty)
? CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: cache,
memCacheHeight: cache,
errorWidget: (_, _, _) => placeholder,
)
: placeholder,
);
}
}
+224 -229
View File
@@ -7,6 +7,7 @@ import '../../backend/modules/messages.dart';
import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart'; import '../../core/config/app_bubble_shape.dart';
import '../../core/utils/bubble_radius.dart'; import '../../core/utils/bubble_radius.dart';
import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
import '../../core/utils/file_download.dart'; import '../../core/utils/file_download.dart';
import '../../core/utils/media_cache.dart'; import '../../core/utils/media_cache.dart';
@@ -58,13 +59,14 @@ class MessageBubble extends StatelessWidget {
static const Radius _photoRadius = Radius.circular(photoBorderRadius); static const Radius _photoRadius = Radius.circular(photoBorderRadius);
static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18);
static const BorderRadius _reactionChipRadius = static const BorderRadius _reactionChipRadius = BorderRadius.all(
BorderRadius.all(Radius.circular(10)); Radius.circular(10),
);
static Color bubbleTextColor(BuildContext context) => static Color bubbleTextColor(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark Theme.of(context).brightness == Brightness.dark
? Colors.white ? Colors.white
: Colors.black; : Colors.black;
final CachedMessage message; final CachedMessage message;
final bool isMe; final bool isMe;
@@ -108,13 +110,15 @@ class MessageBubble extends StatelessWidget {
final hasPrevFromMe = final hasPrevFromMe =
prevMessage?.senderId == message.senderId && !prevMessage!.isControl; prevMessage?.senderId == message.senderId && !prevMessage!.isControl;
final prevTimeDiff = final prevTimeDiff = hasPrevFromMe
hasPrevFromMe ? message.time - prevMessage!.time : 999999999; ? message.time - prevMessage!.time
: 999999999;
final hasNextFromMe = final hasNextFromMe =
nextMessage?.senderId == message.senderId && !nextMessage!.isControl; nextMessage?.senderId == message.senderId && !nextMessage!.isControl;
final nextTimeDiff = final nextTimeDiff = hasNextFromMe
hasNextFromMe ? nextMessage!.time - message.time : 999999999; ? nextMessage!.time - message.time
: 999999999;
final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000;
final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000;
@@ -133,9 +137,11 @@ class MessageBubble extends StatelessWidget {
if (first is ForwardedMessageAttachment) { if (first is ForwardedMessageAttachment) {
final fwd = first; final fwd = first;
final hasContact = fwd.originalContact != null; final hasContact = fwd.originalContact != null;
final hasPhoto = fwd.originalAttachments != null && final hasPhoto =
fwd.originalAttachments != null &&
fwd.originalAttachments!.any((a) => a is PhotoAttachment); fwd.originalAttachments!.any((a) => a is PhotoAttachment);
final hasOther = fwd.originalAttachments != null && final hasOther =
fwd.originalAttachments != null &&
fwd.originalAttachments!.isNotEmpty; fwd.originalAttachments!.isNotEmpty;
if (hasContact || hasPhoto || hasOther) return MessageType.attachment; if (hasContact || hasPhoto || hasOther) return MessageType.attachment;
return MessageType.text; return MessageType.text;
@@ -219,8 +225,10 @@ class MessageBubble extends StatelessWidget {
bool hasPhotoWithCaption, bool hasPhotoWithCaption,
bool hasMultiplePhotosNoCaption, bool hasMultiplePhotosNoCaption,
) { ) {
final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle; final isTop =
final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle; shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
final isBottom =
shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
return computeBubbleRadius( return computeBubbleRadius(
isMe: isMe, isMe: isMe,
isTop: isTop, isTop: isTop,
@@ -238,7 +246,11 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty) { if (senderAvatar != null && senderAvatar.isNotEmpty) {
return CircleAvatar( return CircleAvatar(
radius: 15, radius: 15,
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: cs.primaryContainer, backgroundColor: cs.primaryContainer,
); );
} }
@@ -280,36 +292,35 @@ class MessageBubble extends StatelessWidget {
final padding = _paddingFor(contentType, shape); final padding = _paddingFor(contentType, shape);
final showAvatarSlot = !isMe; final showAvatarSlot = !isMe;
final showAvatar = showAvatarSlot && final showAvatar =
showAvatarSlot &&
chatType == "CHAT" && chatType == "CHAT" &&
nextMessage?.senderId != message.senderId; nextMessage?.senderId != message.senderId;
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
final bubbleColor = final bubbleColor = isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
_BubbleCtx makeCtx() => _BubbleCtx( _BubbleCtx makeCtx() => _BubbleCtx(
context: context, context: context,
cs: cs, cs: cs,
text: textColor, text: textColor,
shape: shape, shape: shape,
contentType: contentType, contentType: contentType,
hasPhotoWithCaption: hasPhotoCap, hasPhotoWithCaption: hasPhotoCap,
hasMultiplePhotosNoCaption: hasMultiPhotos, hasMultiplePhotosNoCaption: hasMultiPhotos,
reactionInfo: _resolveReactionInfo(), reactionInfo: _resolveReactionInfo(),
); );
final Widget bubbleContent = final Widget bubbleContent =
reactionsListenable != null && contentType == MessageType.text reactionsListenable != null && contentType == MessageType.text
? ValueListenableBuilder<Map<String, dynamic>?>( ? ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: reactionsListenable!, valueListenable: reactionsListenable!,
builder: (context, _, _) => _buildContent(makeCtx()), builder: (context, _, _) => _buildContent(makeCtx()),
) )
: _buildContent(makeCtx()); : _buildContent(makeCtx());
final reactionsUnder = _reactionsUnderBubble(contentType); final reactionsUnder = _reactionsUnderBubble(contentType);
final reactionsInside = final reactionsInside = contentType != MessageType.text && !reactionsUnder;
contentType != MessageType.text && !reactionsUnder;
return GestureDetector( return GestureDetector(
onTap: Haptics.tap, onTap: Haptics.tap,
@@ -322,8 +333,9 @@ class MessageBubble extends StatelessWidget {
), ),
child: Align( child: Align(
child: Row( child: Row(
mainAxisAlignment: mainAxisAlignment: isMe
isMe ? MainAxisAlignment.end : MainAxisAlignment.start, ? MainAxisAlignment.end
: MainAxisAlignment.start,
spacing: 8, spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
@@ -337,8 +349,9 @@ class MessageBubble extends StatelessWidget {
backgroundColor: Color(0x00000000), backgroundColor: Color(0x00000000),
), ),
Column( Column(
crossAxisAlignment: crossAxisAlignment: isMe
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, ? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
ListenableBuilder( ListenableBuilder(
listenable: Listenable.merge([ listenable: Listenable.merge([
@@ -540,7 +553,8 @@ class MessageBubble extends StatelessWidget {
Widget _buildTextContent(_BubbleCtx ctx) { Widget _buildTextContent(_BubbleCtx ctx) {
final attachments = message.attachments; final attachments = message.attachments;
final isForwardedContact = attachments != null && final isForwardedContact =
attachments != null &&
attachments.isNotEmpty && attachments.isNotEmpty &&
attachments.first is ForwardedMessageAttachment && attachments.first is ForwardedMessageAttachment &&
(attachments.first as ForwardedMessageAttachment).originalContact != (attachments.first as ForwardedMessageAttachment).originalContact !=
@@ -558,21 +572,18 @@ class MessageBubble extends StatelessWidget {
? _buildForwardedInlineText(ctx, forwarded) ? _buildForwardedInlineText(ctx, forwarded)
: Text( : Text(
message.text ?? '', message.text ?? '',
style: TextStyle( style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
color: ctx.text,
fontSize: 16,
height: 1.3,
),
); );
final metaWidget = Text( final metaWidget = Text(
message.status == 'EDITED' message.status == 'EDITED'
? '${_formatTime(message.time)} ред.' ? '${formatClock(DateTime.fromMillisecondsSinceEpoch(message.time))} ред.'
: _formatTime(message.time), : formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
style: TextStyle(color: ctx.dim, fontSize: 10), style: TextStyle(color: ctx.dim, fontSize: 10),
); );
final showSender = message.senderId != message.accountId && final showSender =
message.senderId != message.accountId &&
prevMessage?.senderId != message.senderId && prevMessage?.senderId != message.senderId &&
chatType == "CHAT"; chatType == "CHAT";
@@ -604,10 +615,7 @@ class MessageBubble extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: metaWidget, child: metaWidget,
), ),
if (isMe) ...[ if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
const SizedBox(width: 4),
_buildStatusIcon(ctx),
],
], ],
), ),
], ],
@@ -625,21 +633,18 @@ class MessageBubble extends StatelessWidget {
style: TextStyle(color: ctx.text), style: TextStyle(color: ctx.text),
), ),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible(child: textWidget), Flexible(child: textWidget),
const SizedBox(width: 8), const SizedBox(width: 8),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: metaWidget, child: metaWidget,
), ),
if (isMe) ...[ if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
const SizedBox(width: 4), ],
_buildStatusIcon(ctx), ),
],
],
),
], ],
); );
} }
@@ -667,7 +672,11 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty) if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar( CircleAvatar(
radius: 10, radius: 10,
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer, backgroundColor: ctx.cs.primaryContainer,
) )
else else
@@ -735,8 +744,9 @@ class MessageBubble extends StatelessWidget {
if (fwd.originalContact != null) { if (fwd.originalContact != null) {
return _buildForwardedContactContent(ctx, fwd); return _buildForwardedContactContent(ctx, fwd);
} }
final photos = final photos = fwd.originalAttachments
fwd.originalAttachments?.whereType<PhotoAttachment>().toList(); ?.whereType<PhotoAttachment>()
.toList();
if (photos != null && photos.isNotEmpty) { if (photos != null && photos.isNotEmpty) {
return _buildForwardedPhotoContent(ctx, fwd, photos); return _buildForwardedPhotoContent(ctx, fwd, photos);
} }
@@ -885,7 +895,11 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty) if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar( CircleAvatar(
radius: 10, radius: 10,
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer, backgroundColor: ctx.cs.primaryContainer,
) )
else else
@@ -954,7 +968,11 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty) if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar( CircleAvatar(
radius: 10, radius: 10,
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer, backgroundColor: ctx.cs.primaryContainer,
) )
else else
@@ -1007,10 +1025,12 @@ class MessageBubble extends StatelessWidget {
final matchBottom = !ctx.hasPhotoWithCaption; final matchBottom = !ctx.hasPhotoWithCaption;
final topR = matchTop ? _bigRadius : _photoRadius; final topR = matchTop ? _bigRadius : _photoRadius;
final bottomL = final bottomL = matchBottom
matchBottom ? (isMe ? _bigRadius : _smallRadius) : _smallRadius; ? (isMe ? _bigRadius : _smallRadius)
final bottomR = : _smallRadius;
matchBottom ? (isMe ? _smallRadius : _bigRadius) : _smallRadius; final bottomR = matchBottom
? (isMe ? _smallRadius : _bigRadius)
: _smallRadius;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
@@ -1123,8 +1143,8 @@ class MessageBubble extends StatelessWidget {
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
final imageUrl = photo.baseUrl ?? ''; final imageUrl = photo.baseUrl ?? '';
final cachePx = final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); .round();
return AspectRatio( return AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Stack( child: Stack(
@@ -1160,8 +1180,8 @@ class MessageBubble extends StatelessWidget {
String overlay, String overlay,
) { ) {
final imageUrl = photo.baseUrl ?? ''; final imageUrl = photo.baseUrl ?? '';
final cachePx = final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); .round();
return AspectRatio( return AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Stack( child: Stack(
@@ -1270,8 +1290,11 @@ class MessageBubble extends StatelessWidget {
color: Colors.black54, color: Colors.black54,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Symbols.play_arrow, child: const Icon(
color: Colors.white, size: 30), Symbols.play_arrow,
color: Colors.white,
size: 30,
),
), ),
), ),
Positioned.fill( Positioned.fill(
@@ -1289,10 +1312,7 @@ class MessageBubble extends StatelessWidget {
); );
} }
Future<void> _playVideo( Future<void> _playVideo(BuildContext context, MessageAttachment video) async {
BuildContext context,
MessageAttachment video,
) async {
final videoId = (video as dynamic).videoId as int?; final videoId = (video as dynamic).videoId as int?;
final token = (video as dynamic).videoToken as String?; final token = (video as dynamic).videoToken as String?;
if (videoId == null) { if (videoId == null) {
@@ -1335,125 +1355,123 @@ class MessageBubble extends StatelessWidget {
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
final name = (file as dynamic).name as String? ?? 'File'; final name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0; final size = (file as dynamic).size as int? ?? 0;
final sizeStr = _formatFileSize(size); final sizeStr = formatBytes(size);
final fileId = (file as dynamic).fileId as int?; final fileId = (file as dynamic).fileId as int?;
final cacheName = '${fileId}_$name'; final cacheName = '${fileId}_$name';
return IntrinsicWidth( return IntrinsicWidth(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
width: 38, width: 38,
height: 38, height: 38,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isMe color: isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
: ctx.cs.primaryContainer, : ctx.cs.primaryContainer,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
),
child: Icon(
Symbols.description,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 20,
),
), ),
child: Icon( const SizedBox(width: 10),
Symbols.description, Flexible(
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, child: Column(
size: 20, crossAxisAlignment: CrossAxisAlignment.start,
), mainAxisSize: MainAxisSize.min,
), children: [
const SizedBox(width: 10), Text(
Flexible( name,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(cacheName),
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
: sizeStr,
style: TextStyle( style: TextStyle(
color: ctx.dim, color: ctx.text,
fontSize: 12, fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2, height: 1.2,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), const SizedBox(height: 2),
], ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(
cacheName,
),
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
: sizeStr,
style: TextStyle(
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
),
),
],
),
), ),
), const SizedBox(width: 12),
const SizedBox(width: 12), ValueListenableBuilder<double?>(
ValueListenableBuilder<double?>( valueListenable: MediaDownloadProgress.notifier(cacheName),
valueListenable: MediaDownloadProgress.notifier(cacheName), builder: (context, progress, _) {
builder: (context, progress, _) { final downloading = progress != null;
final downloading = progress != null; return GestureDetector(
return GestureDetector( onTap: downloading
onTap: downloading ? null
? null : () => _downloadFile(ctx.context, file, name),
: () => _downloadFile(ctx.context, file, name), child: Container(
child: Container( width: 34,
width: 34, height: 34,
height: 34, decoration: BoxDecoration(
decoration: BoxDecoration( color: isMe
color: isMe ? ctx.cs.onPrimaryContainer.withValues(
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) alpha: 0.12,
: ctx.cs.surfaceContainerHighest, )
shape: BoxShape.circle, : ctx.cs.surfaceContainerHighest,
), shape: BoxShape.circle,
child: downloading ),
? Padding( child: downloading
padding: const EdgeInsets.all(8), ? Padding(
child: CircularProgressIndicator( padding: const EdgeInsets.all(8),
strokeWidth: 2, child: CircularProgressIndicator(
value: progress > 0 ? progress : null, strokeWidth: 2,
value: progress > 0 ? progress : null,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
),
)
: Icon(
Symbols.download,
color: isMe color: isMe
? ctx.cs.onPrimaryContainer ? ctx.cs.onPrimaryContainer
: ctx.cs.primary, : ctx.cs.primary,
size: 18,
), ),
) ),
: Icon( );
Symbols.download, },
color: isMe ),
? ctx.cs.onPrimaryContainer ],
: ctx.cs.primary, ),
size: 18, _buildMeta(ctx),
), ],
), ),
);
},
),
],
),
_buildMeta(ctx),
],
), ),
),
); );
} }
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ';
}
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
final url = sticker.baseUrl ?? ''; final url = sticker.baseUrl ?? '';
final preview = sticker.previewData ?? ''; final preview = sticker.previewData ?? '';
@@ -1533,8 +1551,7 @@ class MessageBubble extends StatelessWidget {
) )
: Icon( : Icon(
Symbols.person, Symbols.person,
color: color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 24, size: 24,
), ),
), ),
@@ -1559,11 +1576,7 @@ class MessageBubble extends StatelessWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
contactData.phoneNumber!, contactData.phoneNumber!,
style: TextStyle( style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2),
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
), ),
], ],
], ],
@@ -1614,7 +1627,11 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty) if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar( CircleAvatar(
radius: 10, radius: 10,
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer, backgroundColor: ctx.cs.primaryContainer,
) )
else else
@@ -1816,13 +1833,10 @@ class MessageBubble extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Text( Text(
_formatTime(message.time), formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
style: TextStyle(color: ctx.dim, fontSize: 11), style: TextStyle(color: ctx.dim, fontSize: 11),
), ),
if (isMe) ...[ if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
const SizedBox(width: 4),
_buildStatusIcon(ctx),
],
], ],
), ),
); );
@@ -1840,7 +1854,7 @@ class MessageBubble extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
_formatTime(message.time), formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 10,
@@ -1880,13 +1894,6 @@ class MessageBubble extends StatelessWidget {
return Icon(icon, size: 14, color: color); return Icon(icon, size: 14, color: color);
} }
String _formatTime(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
} }
class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubble extends StatefulWidget {
@@ -1941,19 +1948,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
super.dispose(); super.dispose();
} }
String _formatDuration(int seconds) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
String _formatTime(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
Widget _buildStatusIcon() { Widget _buildStatusIcon() {
final status = widget.status; final status = widget.status;
IconData icon; IconData icon;
@@ -2050,16 +2044,17 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
), ),
child: ValueListenableBuilder<double>( child: ValueListenableBuilder<double>(
valueListenable: _progress, valueListenable: _progress,
builder: (context, progress, _) => FractionallySizedBox( builder: (context, progress, _) =>
alignment: Alignment.centerLeft, FractionallySizedBox(
widthFactor: progress.clamp(0.0, 1.0), alignment: Alignment.centerLeft,
child: Container( widthFactor: progress.clamp(0.0, 1.0),
decoration: BoxDecoration( child: Container(
color: waveActiveColor, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2), color: waveActiveColor,
borderRadius: BorderRadius.circular(2),
),
),
), ),
),
),
), ),
), ),
); );
@@ -2103,7 +2098,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
width: 32, width: 32,
child: Center( child: Center(
child: Text( child: Text(
_formatDuration(widget.duration), formatSecondsMmSs(widget.duration),
style: TextStyle( style: TextStyle(
color: widget.textColor.withValues(alpha: 0.7), color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11, fontSize: 11,
@@ -2133,7 +2128,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
), ),
if (!_transcriptionVisible) ...[ if (!_transcriptionVisible) ...[
Text( Text(
_formatTime(widget.time), formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)),
style: TextStyle( style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6), color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10, fontSize: 10,
@@ -2151,7 +2146,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Text( Text(
_formatTime(widget.time), formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)),
style: TextStyle( style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6), color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10, fontSize: 10,
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
/// Small primary-colored section title used across settings/profile screens.
class SectionHeader extends StatelessWidget {
final String title;
final EdgeInsetsGeometry padding;
final double fontSize;
const SectionHeader(
this.title, {
super.key,
this.padding = const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4),
this.fontSize = 13,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: padding,
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: fontSize,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// Standard rounded top shape for modal bottom sheets.
const RoundedRectangleBorder kSheetShape = RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
);
/// The little drag "grabber" pill shown at the top of a bottom sheet.
class SheetGrabber extends StatelessWidget {
final EdgeInsetsGeometry margin;
const SheetGrabber({
super.key,
this.margin = const EdgeInsets.symmetric(vertical: 10),
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
width: 40,
height: 4,
margin: margin,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
);
}
}