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