немножко уи попердолил
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Komet is a cross-platform Flutter messaging client (Android, iOS, macOS, Windows, Linux, Web) that communicates via a custom packet-based protocol with MessagePack serialization and Zstd compression.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
flutter pub get # install dependencies
|
||||
flutter analyze # lint / static analysis
|
||||
flutter run # run on connected device (default: komet flavor)
|
||||
flutter run --flavor oneme -t lib/main.dart # run oneme flavor (FCM)
|
||||
|
||||
# Android builds
|
||||
flutter build apk --release --flavor komet
|
||||
flutter build apk --release --split-per-abi --flavor komet
|
||||
flutter build appbundle --release --flavor komet
|
||||
|
||||
# Other platforms
|
||||
flutter build ios --release --no-codesign
|
||||
flutter build macos --release
|
||||
flutter build web --release
|
||||
flutter build linux --release
|
||||
flutter build windows --release
|
||||
```
|
||||
|
||||
Android builds require **Java 17**. Gradle memory is configured to `-Xmx4096m`.
|
||||
|
||||
## Build Flavors
|
||||
|
||||
| Flavor | App ID | Notes |
|
||||
|--------|--------|-------|
|
||||
| `komet` | `ru.komet.app` | Default, no FCM |
|
||||
| `oneme` | `ru.oneme.app` | FCM push notifications via Firebase |
|
||||
|
||||
Flavor-specific Android resources live in `android/app/src/komet/` and `android/app/src/oneme/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
The codebase follows a strict layered architecture:
|
||||
|
||||
```
|
||||
core/transport/ — raw socket I/O: connection, sender, receiver, dispatcher, proxy
|
||||
core/protocol/ — Packet struct, opcode map, MessagePack + Zstd serialization
|
||||
core/storage/ — SQLite (sqflite), secure token storage, spoofing service
|
||||
core/push/ — FCM integration (oneme flavor only)
|
||||
core/config/ — app config, proxy config, device presets, countries list
|
||||
|
||||
backend/api.dart — session lifecycle: connect, handshake, ping, auto-reconnect
|
||||
backend/modules/ — feature modules: account, messages, chats, contacts, calls, folders
|
||||
|
||||
state/ — ChangeNotifier state classes consumed by the UI
|
||||
models/ — plain data classes (User, Chat, Message, Call, Attachment, Session)
|
||||
|
||||
frontend/screens/ — full-page widgets grouped by feature (auth/, chats/, contacts/, calls/, profile/)
|
||||
frontend/widgets/ — reusable components (message_bubble, chat_tile, avatar, etc.)
|
||||
```
|
||||
|
||||
Data flow: UI → backend module → `api.dart` → transport layer → server.
|
||||
Incoming packets: transport → dispatcher → backend module → state → UI rebuild.
|
||||
|
||||
## Key Conventions (from AGENTS.md)
|
||||
|
||||
- **No comments in code.** Write self-documenting code instead.
|
||||
- **Use `showCustomNotification(context, 'text')`** for all user-facing notifications — never use SnackBars.
|
||||
- When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**.
|
||||
- Quality over quantity.
|
||||
|
||||
## Localization
|
||||
|
||||
Two locales supported: English (`lib/l10n/app_en.arb`) and Russian (`lib/l10n/app_ru.arb`).
|
||||
Generated code is in `lib/l10n/` (produced by `flutter gen-l10n` via `l10n.yaml`).
|
||||
|
||||
## CI/CD
|
||||
|
||||
Four GitHub Actions workflows in `.github/workflows/`:
|
||||
|
||||
- `flutter-dev.yml` — PR lint + Android build for dev branch
|
||||
- `flutter-main.yml` — PR lint + all-platform builds for main branch
|
||||
- `build-android.yml` — production APKs + AAB (`komet` flavor), triggered on push to main
|
||||
- `build-android-fcm.yml` — production APKs + AAB (`oneme` flavor with FCM), triggered on push to main
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
@@ -43,9 +43,13 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final _tabScrollController = ScrollController();
|
||||
|
||||
int _myId = 0;
|
||||
bool _isLoading = true;
|
||||
Map<String, dynamic>? _chatData;
|
||||
String _selectedTab = '';
|
||||
bool _descExpanded = false;
|
||||
|
||||
// DIALOG
|
||||
int? _otherId;
|
||||
@@ -54,8 +58,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
bool _isOnline = false;
|
||||
bool _isBot = false;
|
||||
|
||||
bool _infoExpanded = false;
|
||||
|
||||
// CHAT
|
||||
List<_MemberInfo> _members = [];
|
||||
int _onlineCount = 0;
|
||||
@@ -66,6 +68,27 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<String> get _tabs {
|
||||
switch (widget.chatType) {
|
||||
case 'DIALOG':
|
||||
return _isBot
|
||||
? ['Info', 'Медиа', 'Файлы', 'Голосовые', 'Ссылки']
|
||||
: ['Общие чаты', 'Медиа', 'Info', 'Файлы', 'Голосовые', 'Ссылки'];
|
||||
case 'CHAT':
|
||||
return ['Участники', 'Info', 'Медиа', 'Файлы', 'Голосовые', 'Ссылки'];
|
||||
case 'CHANNEL':
|
||||
return ['Info', 'Медиа', 'Файлы', 'Голосовые', 'Ссылки'];
|
||||
default:
|
||||
return ['Info'];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
_myId = profile?.id ?? 0;
|
||||
@@ -144,8 +167,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final presence = (pp.payload as Map?)?['presence'] as Map?;
|
||||
if (presence != null) {
|
||||
for (final e in presence.entries) {
|
||||
final id = e.key is int ? e.key as int : int.tryParse(e.key.toString());
|
||||
if (id != null && e.value is Map) presenceMap[id] = e.value as Map;
|
||||
final id = e.key is int
|
||||
? e.key as int
|
||||
: int.tryParse(e.key.toString());
|
||||
if (id != null && e.value is Map) {
|
||||
presenceMap[id] = e.value as Map;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +202,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
if (_selectedTab.isEmpty) _selectedTab = _tabs.first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUILD ───────────────────────────────────────────────────────────────
|
||||
@@ -202,15 +234,14 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
elevation: 0,
|
||||
floating: true,
|
||||
leading: IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
if (widget.chatType == 'DIALOG' && !_isBot)
|
||||
IconButton(
|
||||
icon: Icon(Symbols.edit, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildBody(cs)),
|
||||
@@ -222,6 +253,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
_buildAvatar(cs),
|
||||
@@ -244,11 +276,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
const SizedBox(height: 20),
|
||||
_buildActions(cs),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (ctx, constraints) =>
|
||||
_buildInfoArea(cs, constraints.maxWidth),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
_buildPersistentInfo(cs),
|
||||
_buildTabBar(cs),
|
||||
const SizedBox(height: 12),
|
||||
_buildTabContent(cs),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -260,7 +292,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: cs.primaryContainer),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle, color: cs.primaryContainer),
|
||||
child: widget.imageUrl.isNotEmpty
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
@@ -289,11 +322,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
String _subtitle() {
|
||||
switch (widget.chatType) {
|
||||
case 'DIALOG':
|
||||
if (_isBot) {
|
||||
final link = _contactData?['link'] as String?;
|
||||
final handle = link != null ? '@${Uri.parse(link).pathSegments.last}' : '';
|
||||
return '$handle · Бот'.trim();
|
||||
}
|
||||
if (_isBot) return 'Бот';
|
||||
if (_isOnline) return 'В сети';
|
||||
if (_seenTime != null) return _formatLastSeen(_seenTime!);
|
||||
return '';
|
||||
@@ -318,40 +347,46 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
if (_isBot) {
|
||||
btns = [
|
||||
(icon: Symbols.chat_bubble, label: 'Цат'),
|
||||
(icon: Symbols.notifications, label: 'Звук'),
|
||||
(icon: Symbols.more_horiz, label: 'Ещё'),
|
||||
(icon: Icons.chat_bubble, label: 'Чат'),
|
||||
(icon: Icons.notifications, label: 'Звук'),
|
||||
];
|
||||
} else {
|
||||
btns = [
|
||||
(icon: Symbols.call, label: 'Звонок'),
|
||||
(icon: Symbols.videocam, label: 'Видео'),
|
||||
(icon: Symbols.notifications, label: 'Звук'),
|
||||
(icon: Symbols.more_horiz, label: 'Ещё'),
|
||||
(icon: Icons.chat_bubble, label: 'Чат'),
|
||||
(icon: Icons.notifications, label: 'Звук'),
|
||||
(icon: Icons.call, label: 'Звонок'),
|
||||
];
|
||||
}
|
||||
} else if (widget.chatType == 'CHANNEL') {
|
||||
btns = [
|
||||
(icon: Icons.notifications, label: 'Звук'),
|
||||
(icon: Icons.exit_to_app, label: 'Покинуть'),
|
||||
];
|
||||
} else {
|
||||
btns = [
|
||||
(icon: Symbols.notifications, label: 'Звук'),
|
||||
(icon: Symbols.search, label: 'Найти'),
|
||||
(icon: Symbols.more_horiz, label: 'Ещё'),
|
||||
(icon: Icons.chat_bubble, label: 'Чат'),
|
||||
(icon: Icons.notifications, label: 'Звук'),
|
||||
(icon: Icons.exit_to_app, label: 'Покинуть'),
|
||||
];
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
for (int i = 0; i < btns.length; i++) ...[
|
||||
_actionBtn(cs, btns[i].icon, btns[i].label),
|
||||
if (i < btns.length - 1) const SizedBox(width: 8),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < btns.length; i++) ...[
|
||||
_actionBtn(cs, btns[i].icon, btns[i].label),
|
||||
if (i < btns.length - 1) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionBtn(ColorScheme cs, IconData icon, String label) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
@@ -359,109 +394,324 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFF007AFF), size: 24),
|
||||
const SizedBox(height: 5),
|
||||
Text(label, style: TextStyle(color: cs.onSurface, fontSize: 12)),
|
||||
Icon(icon, color: const Color(0xFF007AFF), size: 22),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SECTIONS ────────────────────────────────────────────────────────────
|
||||
// ─── PERSISTENT INFO (shown above tabs for all types) ────────────────────
|
||||
|
||||
List<Widget> _buildSections(ColorScheme cs) {
|
||||
switch (widget.chatType) {
|
||||
case 'DIALOG':
|
||||
return _dialogSections(cs);
|
||||
case 'CHAT':
|
||||
return _groupSections(cs);
|
||||
case 'CHANNEL':
|
||||
return _channelSections(cs);
|
||||
default:
|
||||
return [_attachmentsCard(cs)];
|
||||
}
|
||||
}
|
||||
Widget _buildPersistentInfo(ColorScheme cs) {
|
||||
final items = <Widget>[];
|
||||
|
||||
List<Widget> _dialogSections(ColorScheme cs) {
|
||||
final result = <Widget>[];
|
||||
|
||||
if (_isBot) {
|
||||
final link = _contactData?['link'] as String?;
|
||||
if (link != null) {
|
||||
result
|
||||
..add(_linkCard(cs, link))
|
||||
..add(const SizedBox(height: 8));
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
if (_isBot) {
|
||||
final link = _contactData?['link'] as String?;
|
||||
if (link != null && link.isNotEmpty) {
|
||||
items.add(_simpleInfoCard(cs, 'Ссылка', link, isLink: true));
|
||||
}
|
||||
} else {
|
||||
final phone = _contactData?['phone'];
|
||||
final phoneInt =
|
||||
phone is int ? phone : int.tryParse(phone?.toString() ?? '');
|
||||
if (phoneInt != null && phoneInt > 0) {
|
||||
items.add(_simpleInfoCard(cs, 'Номер телефона', _formatPhone(phoneInt)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final phone = _contactData?['phone'];
|
||||
final phoneInt = phone is int ? phone : int.tryParse(phone?.toString() ?? '');
|
||||
if (phoneInt != null && phoneInt > 0) {
|
||||
result
|
||||
..add(_infoCard(cs, 'Номер телефона', _formatPhone(phoneInt)))
|
||||
..add(const SizedBox(height: 8));
|
||||
} else if (widget.chatType == 'CHANNEL') {
|
||||
final link = _chatData?['link'] as String?;
|
||||
if (link != null && link.isNotEmpty) {
|
||||
items.add(_linkCard(cs, link));
|
||||
}
|
||||
final desc = _chatData?['description'] as String?;
|
||||
if (desc != null && desc.isNotEmpty) {
|
||||
if (items.isNotEmpty) items.add(const SizedBox(height: 8));
|
||||
items.add(_collapsibleDescCard(cs, desc));
|
||||
}
|
||||
}
|
||||
|
||||
result.add(_attachmentsCard(cs));
|
||||
return result;
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [...items, const SizedBox(height: 16)],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _groupSections(ColorScheme cs) {
|
||||
return [
|
||||
_attachmentsCard(cs),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
'УЧАСТНИКИ',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
Widget _simpleInfoCard(ColorScheme cs, String label, String value,
|
||||
{bool isLink = false}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: isLink ? const Color(0xFF007AFF) : cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _linkCard(ColorScheme cs, String link) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Ссылка-приглашение',
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(link,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF007AFF), fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_2,
|
||||
color: Color(0xFF007AFF), size: 22),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _collapsibleDescCard(ColorScheme cs, String desc) {
|
||||
const int collapsedLines = 3;
|
||||
final isLong = desc.length > 120;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Описание',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
desc,
|
||||
style:
|
||||
TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
|
||||
maxLines: (_descExpanded || !isLong) ? null : collapsedLines,
|
||||
overflow:
|
||||
(_descExpanded || !isLong) ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
if (isLong) ...[
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _descExpanded = !_descExpanded),
|
||||
child: Text(
|
||||
_descExpanded ? 'Свернуть' : 'Ещё',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF007AFF), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TAB BAR ─────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildTabBar(ColorScheme cs) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
},
|
||||
),
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
final delta = event.scrollDelta.dy != 0
|
||||
? event.scrollDelta.dy
|
||||
: event.scrollDelta.dx;
|
||||
_tabScrollController.animateTo(
|
||||
(_tabScrollController.offset + delta).clamp(
|
||||
_tabScrollController.position.minScrollExtent,
|
||||
_tabScrollController.position.maxScrollExtent,
|
||||
),
|
||||
duration: const Duration(milliseconds: 80),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: _tabScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (int i = 0; i < _tabs.length; i++) ...[
|
||||
_tabChip(cs, _tabs[i]),
|
||||
if (i < _tabs.length - 1) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabChip(ColorScheme cs, String tab) {
|
||||
final selected = tab == _selectedTab;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedTab = tab),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: selected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_memberAction(cs, Symbols.person_add, 'Добавить участника', () {}),
|
||||
_listDivider(cs),
|
||||
_memberAction(cs, Symbols.link, 'Пригласить по ссылке', () {}),
|
||||
..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
|
||||
],
|
||||
child: Text(
|
||||
tab,
|
||||
style: TextStyle(
|
||||
color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _channelSections(ColorScheme cs) {
|
||||
final result = <Widget>[];
|
||||
// ─── TAB CONTENT ─────────────────────────────────────────────────────────
|
||||
|
||||
final link = _chatData?['link'] as String?;
|
||||
if (link != null) {
|
||||
result
|
||||
..add(_linkCard(cs, link))
|
||||
..add(const SizedBox(height: 8));
|
||||
}
|
||||
|
||||
final desc = _chatData?['description'] as String?;
|
||||
if (desc != null && desc.isNotEmpty) {
|
||||
result
|
||||
..add(_descCard(cs, desc))
|
||||
..add(const SizedBox(height: 8));
|
||||
}
|
||||
|
||||
result.add(_attachmentsCard(cs));
|
||||
return result;
|
||||
Widget _buildTabContent(ColorScheme cs) {
|
||||
if (_selectedTab.isEmpty) return const SizedBox.shrink();
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(_selectedTab),
|
||||
child: _tabBody(cs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CARD WIDGETS ────────────────────────────────────────────────────────
|
||||
Widget _tabBody(ColorScheme cs) {
|
||||
switch (_selectedTab) {
|
||||
case 'Info':
|
||||
return _buildInfoTabContent(cs);
|
||||
case 'Участники':
|
||||
return _buildMembersTabContent(cs);
|
||||
case 'Общие чаты':
|
||||
return _buildPlaceholder(cs, 'Нет общих чатов', Icons.group);
|
||||
case 'Медиа':
|
||||
return _buildPlaceholder(cs, 'Нет медиа', Icons.photo_library);
|
||||
case 'Файлы':
|
||||
return _buildPlaceholder(cs, 'Нет файлов', Icons.description);
|
||||
case 'Голосовые':
|
||||
return _buildPlaceholder(cs, 'Нет голосовых', Icons.mic);
|
||||
case 'Ссылки':
|
||||
return _buildPlaceholder(cs, 'Нет ссылок', Icons.link);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(ColorScheme cs, String label, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── INFO TAB ────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildInfoTabContent(ColorScheme cs) {
|
||||
final items = <Widget>[];
|
||||
|
||||
if (widget.chatType == 'DIALOG' && !_isBot) {
|
||||
final bio = (_contactData?['description'] as String?) ??
|
||||
(_contactData?['about'] as String?);
|
||||
if (bio != null && bio.isNotEmpty) {
|
||||
items
|
||||
..add(_infoCard(cs, 'О себе', bio))
|
||||
..add(const SizedBox(height: 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.chatType == 'CHAT') {
|
||||
final desc = _chatData?['description'] as String?;
|
||||
if (desc != null && desc.isNotEmpty) {
|
||||
items
|
||||
..add(_infoCard(cs, 'Описание', desc))
|
||||
..add(const SizedBox(height: 8));
|
||||
}
|
||||
}
|
||||
|
||||
items.add(_buildInfoRowsCard(cs));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRowsCard(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: _buildAllInfoRows(cs),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard(ColorScheme cs, String label, String value) {
|
||||
return Container(
|
||||
@@ -487,91 +737,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _linkCard(ColorScheme cs, String link) {
|
||||
// ─── MEMBERS TAB ─────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildMembersTabContent(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Ссылка',
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(link,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF007AFF), fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.share,
|
||||
color: Color(0xFF007AFF), size: 22),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.qr_code,
|
||||
color: Color(0xFF007AFF), size: 22),
|
||||
onPressed: () {},
|
||||
),
|
||||
_memberAction(cs, Icons.person_add, 'Добавить участника', () {}),
|
||||
..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _descCard(ColorScheme cs, String desc) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(desc,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _attachmentsCard(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 28),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Вложения',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Text('Фото, видео, файлы и ссылки',
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Symbols.chevron_right, color: cs.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── MEMBER LIST ─────────────────────────────────────────────────────────
|
||||
|
||||
Widget _memberAction(
|
||||
ColorScheme cs, IconData icon, String label, VoidCallback onTap) {
|
||||
return InkWell(
|
||||
@@ -661,180 +843,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── INFO AREA ───────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildInfoArea(ColorScheme cs, double W) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Кнопка "Инфо" — всегда полная ширина
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _infoExpanded = !_infoExpanded),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.info, color: const Color(0xFF007AFF), size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Инфо',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
AnimatedRotation(
|
||||
turns: _infoExpanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
child: Icon(Symbols.keyboard_arrow_down,
|
||||
color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Контент: при закрытии — обычная колонка, при открытии — Row
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
sizeCurve: Curves.easeOut,
|
||||
firstCurve: Curves.easeOut,
|
||||
secondCurve: Curves.easeOut,
|
||||
crossFadeState: _infoExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _buildSections(cs),
|
||||
),
|
||||
secondChild: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildCompactSections(cs)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _buildInfoPanelCard(cs)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Компактные карточки — левая колонка при открытом инфо
|
||||
Widget _buildCompactSections(ColorScheme cs) {
|
||||
final items = <Widget>[];
|
||||
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
if (_isBot) {
|
||||
final link = _contactData?['link'] as String?;
|
||||
if (link != null) items.add(_compactCard(cs, 'Ссылка', link));
|
||||
} else {
|
||||
final phone = _contactData?['phone'];
|
||||
final phoneInt =
|
||||
phone is int ? phone : int.tryParse(phone?.toString() ?? '');
|
||||
if (phoneInt != null && phoneInt > 0) {
|
||||
items.add(_compactCard(cs, 'Телефон', _formatPhone(phoneInt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.chatType == 'CHANNEL') {
|
||||
final desc = _chatData?['description'] as String?;
|
||||
if (desc != null && desc.isNotEmpty) {
|
||||
items.add(_compactCard(
|
||||
cs,
|
||||
'Описание',
|
||||
desc.length > 80 ? '${desc.substring(0, 80)}…' : desc,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.chatType == 'CHAT') {
|
||||
final total = (_chatData?['participantsCount'] as int?) ?? _members.length;
|
||||
items.add(_compactCard(cs, 'Участников', '$total'));
|
||||
}
|
||||
|
||||
items.add(const SizedBox(height: 8));
|
||||
items.add(_compactAttachments(cs));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
items[i],
|
||||
if (i < items.length - 1 && items[i] is! SizedBox)
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _compactCard(ColorScheme cs, String label, String value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)),
|
||||
const SizedBox(height: 3),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _compactAttachments(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text('Вложения',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
),
|
||||
Icon(Symbols.chevron_right, color: cs.onSurfaceVariant, size: 18),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Инфо-панель — правая колонка при открытом инфо
|
||||
Widget _buildInfoPanelCard(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: _buildAllInfoRows(cs),
|
||||
);
|
||||
}
|
||||
// ─── INFO ROWS ────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildAllInfoRows(ColorScheme cs) {
|
||||
final rows = <({String label, String value})>[];
|
||||
@@ -875,8 +884,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
add('Владелец', ContactCache.get(owner) ?? '$owner');
|
||||
}
|
||||
add('Создана', chat['created'], tsFormat: true);
|
||||
add('Вступил', (chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1
|
||||
? chat['joinTime'] : null, tsFormat: true);
|
||||
add(
|
||||
'Вступил',
|
||||
(chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1
|
||||
? chat['joinTime']
|
||||
: null,
|
||||
tsFormat: true,
|
||||
);
|
||||
add('Изменена', chat['modified'], tsFormat: true);
|
||||
add('Есть боты', chat['hasBots'] as bool?);
|
||||
final blocked = chat['blockedParticipantsCount'] as int?;
|
||||
@@ -953,18 +967,17 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 60, 16, 0),
|
||||
children: [
|
||||
Center(
|
||||
child: block(96, 96, r: 48)),
|
||||
Center(child: block(96, 96, r: 48)),
|
||||
const SizedBox(height: 14),
|
||||
Center(child: block(160, 22, r: 8)),
|
||||
const SizedBox(height: 8),
|
||||
Center(child: block(110, 16, r: 6)),
|
||||
const SizedBox(height: 24),
|
||||
block(double.infinity, 70, r: 14),
|
||||
Center(child: block(240, 54, r: 14)),
|
||||
const SizedBox(height: 16),
|
||||
block(double.infinity, 36, r: 20),
|
||||
const SizedBox(height: 12),
|
||||
block(double.infinity, 70, r: 14),
|
||||
const SizedBox(height: 12),
|
||||
block(double.infinity, 70, r: 14),
|
||||
block(double.infinity, 120, r: 14),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,9 +124,9 @@ class MessageBubble extends StatelessWidget {
|
||||
final topRadius = Radius.circular(bubbleBorderRadius);
|
||||
final smallRadius = const Radius.circular(4);
|
||||
|
||||
final cornerTL = isMe ? smallRadius : topRadius;
|
||||
final cornerTL = topRadius;
|
||||
final cornerTR = isMe ? topRadius : smallRadius;
|
||||
final cornerBL = isMe ? smallRadius : topRadius;
|
||||
final cornerBL = topRadius;
|
||||
final cornerBR = isMe ? topRadius : smallRadius;
|
||||
|
||||
if (_hasPhotoWithCaption &&
|
||||
@@ -136,7 +136,7 @@ class MessageBubble extends StatelessWidget {
|
||||
return BorderRadius.only(
|
||||
topLeft: topRadius,
|
||||
topRight: topRadius,
|
||||
bottomLeft: smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
}
|
||||
@@ -145,9 +145,9 @@ class MessageBubble extends StatelessWidget {
|
||||
(shape == BubbleShape.singleBottom ||
|
||||
shape == BubbleShape.singleMiddle)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: smallRadius,
|
||||
topLeft: isMe ? topRadius : smallRadius,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : topRadius,
|
||||
);
|
||||
}
|
||||
@@ -157,12 +157,12 @@ class MessageBubble extends StatelessWidget {
|
||||
return BorderRadius.only(
|
||||
topLeft: cornerTL,
|
||||
topRight: cornerTR,
|
||||
bottomLeft: smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
case BubbleShape.singleBottom:
|
||||
return BorderRadius.only(
|
||||
topLeft: smallRadius,
|
||||
topLeft: isMe ? topRadius : smallRadius,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: cornerBL,
|
||||
bottomRight: cornerBR,
|
||||
|
||||
Reference in New Issue
Block a user