feat: glossy стиль, возможность выбора frost blur. fix: переход к закрепленному сообщению

This commit is contained in:
Jganenokk
2026-07-13 19:12:51 +07:00
parent 10d071bdff
commit c527620a15
27 changed files with 893 additions and 632 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ class AppChatChrome {
static final _setting = PersistedEnum<ChatChromeStyle>(
prefKey: prefKey,
defaultValue: ChatChromeStyle.none,
defaultValue: ChatChromeStyle.transparent,
encode: _encode,
decode: _parse,
);
@@ -17,7 +17,7 @@ class AppChatChrome {
static ValueNotifier<ChatChromeStyle> get current => _setting.current;
static ChatChromeStyle _parse(String? value) =>
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none);
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.transparent);
static String _encode(ChatChromeStyle value) => value.name;
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum ComposerBackground { standard, frostBlur }
class AppComposerBackground {
static const prefKey = 'app_composer_background';
static final _setting = PersistedEnum<ComposerBackground>(
prefKey: prefKey,
defaultValue: ComposerBackground.standard,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<ComposerBackground> get current => _setting.current;
static Future<ComposerBackground> load() => _setting.load();
static Future<void> save(ComposerBackground value) => _setting.save(value);
static ComposerBackground _parse(String? val) =>
enumFromName(ComposerBackground.values, val, ComposerBackground.standard);
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum ComposerStyle { glossy, materialYou }
class AppComposerStyle {
static const prefKey = 'app_composer_style';
static final _setting = PersistedEnum<ComposerStyle>(
prefKey: prefKey,
defaultValue: ComposerStyle.glossy,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<ComposerStyle> get current => _setting.current;
static Future<ComposerStyle> load() => _setting.load();
static Future<void> save(ComposerStyle value) => _setting.save(value);
static ComposerStyle _parse(String? val) =>
enumFromName(ComposerStyle.values, val, ComposerStyle.glossy);
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
class AppFrost {
static const double sigma = 34;
static const double panelSigma = 24;
static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.38);
static Color blurPanelTint(ColorScheme cs) =>
cs.surfaceContainerHigh.withValues(alpha: 0.55);
static Color pillTint(ColorScheme cs) =>
cs.surfaceContainerHigh.withValues(alpha: 0.45);
static Color inputTint(ColorScheme cs) =>
cs.surfaceContainerHighest.withValues(alpha: 0.45);
static BorderSide hairline(ColorScheme cs) =>
BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5);
}
@@ -9,7 +9,7 @@ class AppMessageActionsStyle {
static final _setting = PersistedEnum<MessageActionsStyle>(
prefKey: prefKey,
defaultValue: MessageActionsStyle.radial,
defaultValue: MessageActionsStyle.list,
encode: (value) => value.name,
decode: _parse,
);
@@ -21,7 +21,7 @@ class AppMessageActionsStyle {
static Future<void> save(MessageActionsStyle style) => _setting.save(style);
static MessageActionsStyle _parse(String? val) =>
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.radial);
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.list);
static String label(MessageActionsStyle style) {
switch (style) {
+1 -1
View File
@@ -7,7 +7,7 @@ class AppPillGradient {
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: true,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
+2 -2
View File
@@ -9,7 +9,7 @@ class AppVisualStyle {
static final _setting = PersistedEnum<VisualStyle>(
prefKey: prefKey,
defaultValue: VisualStyle.materialYou,
defaultValue: VisualStyle.glossy,
encode: _encode,
decode: _parse,
);
@@ -23,5 +23,5 @@ class AppVisualStyle {
static String _encode(VisualStyle value) => value.name;
static VisualStyle _parse(String? val) =>
enumFromName(VisualStyle.values, val, VisualStyle.materialYou);
enumFromName(VisualStyle.values, val, VisualStyle.glossy);
}
+50
View File
@@ -928,6 +928,56 @@ class AppDatabase {
);
}
static Future<List<Map<String, dynamic>>> loadMessagesBetween(
int accountId,
int chatId, {
required int afterTime,
required int beforeTime,
int limit = 60,
bool onlyVisible = false,
}) async {
final db = await _instance;
return db.query(
'messages',
where: onlyVisible
? 'account_id = ? AND chat_id = ? AND deleted = 0 '
'AND time > ? AND time < ?'
: 'account_id = ? AND chat_id = ? AND time > ? AND time < ?',
whereArgs: [accountId, chatId, afterTime, beforeTime],
orderBy: 'time ASC',
limit: limit,
);
}
static Future<List<Map<String, dynamic>>> loadMessagesAround(
int accountId,
int chatId, {
required int centerTime,
int before = 40,
int after = 20,
bool onlyVisible = false,
}) async {
final db = await _instance;
final base = onlyVisible
? 'account_id = ? AND chat_id = ? AND deleted = 0'
: 'account_id = ? AND chat_id = ?';
final older = await db.query(
'messages',
where: '$base AND time <= ?',
whereArgs: [accountId, chatId, centerTime],
orderBy: 'time DESC',
limit: before,
);
final newer = await db.query(
'messages',
where: '$base AND time > ?',
whereArgs: [accountId, chatId, centerTime],
orderBy: 'time ASC',
limit: after,
);
return [...newer.reversed, ...older];
}
static Future<void> markMessageDeleted(
int accountId,
int chatId,
@@ -10,9 +10,25 @@ import '../../../../core/storage/app_database.dart';
import '../../../../core/utils/logger.dart';
import '../../../../main.dart';
class HistoryGap {
HistoryGap({
required this.edgeId,
required this.edgeTime,
required this.tailTime,
});
String edgeId;
int edgeTime;
final int tailTime;
}
class ChatController extends ChangeNotifier {
static const int historyPageSize = 30;
static const int historyInitialLimit = 50;
static const int jumpWindowBefore = 40;
static const int jumpWindowAfter = 20;
static const int historyWalkPageSize = 200;
static const int gapPageSize = 60;
int chatId = 0;
int myId = 0;
@@ -23,6 +39,11 @@ class ChatController extends ChangeNotifier {
bool hasMoreHistory = true;
bool isLoadingMore = false;
bool historyKickedOff = false;
bool loadingGap = false;
final List<HistoryGap> gaps = [];
bool get hasGap => gaps.isNotEmpty;
bool Function() isMounted = () => true;
@@ -93,20 +114,179 @@ class ChatController extends ChangeNotifier {
Future<List<CachedMessage>> loadOlderFromDb(
int beforeTime,
bool onlyVisible,
) async {
bool onlyVisible, {
int? limit,
}) async {
final rows = await AppDatabase.loadMessagesBefore(
myId,
chatId,
beforeTime: beforeTime,
limit: historyPageSize,
limit: limit ?? historyPageSize,
onlyVisible: onlyVisible,
);
return CachedMessage.fromDbRowsAsync(rows);
}
Future<List<CachedMessage>> loadGapSliceFromDb(
int afterTime,
int beforeTime,
bool onlyVisible,
) async {
final rows = await AppDatabase.loadMessagesBetween(
myId,
chatId,
afterTime: afterTime,
beforeTime: beforeTime,
limit: gapPageSize,
onlyVisible: onlyVisible,
);
return CachedMessage.fromDbRowsAsync(rows);
}
Future<List<CachedMessage>> loadWindowFromDb(
int centerTime,
bool onlyVisible,
) async {
final rows = await AppDatabase.loadMessagesAround(
myId,
chatId,
centerTime: centerTime,
before: jumpWindowBefore,
after: jumpWindowAfter,
onlyVisible: onlyVisible,
);
return CachedMessage.fromDbRowsAsync(rows);
}
Future<bool> loadMessageWindow({
required String targetId,
required int targetTime,
}) async {
if (myId == 0 || targetTime <= 0) return false;
final onlyVisible = !KometSettings.viewDeleted.value;
var window = await loadWindowFromDb(targetTime, onlyVisible);
if (!isMounted()) return false;
if (!window.any((m) => m.id == targetId)) {
final fetched = await messagesModule.fetchHistory(
myId,
chatId,
fromTime: targetTime + 1,
forward: jumpWindowAfter,
backward: jumpWindowBefore + 1,
);
if (!isMounted()) return false;
if (fetched.isNotEmpty && KometSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
window = await loadWindowFromDb(targetTime, onlyVisible);
if (!isMounted()) return false;
}
if (window.isEmpty) return false;
final oldestLoaded = messages.isEmpty ? 0 : messages.first.time;
final reachesLoaded =
messages.isEmpty || window.any((m) => m.time >= oldestLoaded);
mergeMessages(window);
if (reachesLoaded) {
persistSessionCache();
} else {
_markGapAfterWindow(window);
}
return messages.any((m) => m.id == targetId);
}
void _markGapAfterWindow(List<CachedMessage> window) {
var edge = window.first;
for (final m in window) {
if (m.time > edge.time) edge = m;
}
final idx = messages.indexWhere((m) => m.id == edge.id);
if (idx == -1 || idx + 1 >= messages.length) return;
final tailTime = messages[idx + 1].time;
gaps.removeWhere((g) => g.tailTime == tailTime);
gaps.add(
HistoryGap(edgeId: edge.id, edgeTime: edge.time, tailTime: tailTime),
);
}
void _closeGap(HistoryGap gap) {
gaps.remove(gap);
if (gaps.isEmpty) persistSessionCache();
}
Future<int> fillGapForward(HistoryGap gap) async {
if (loadingGap || myId == 0 || !gaps.contains(gap)) return 0;
if (gap.edgeTime <= 0 || gap.tailTime <= gap.edgeTime) {
_closeGap(gap);
return 0;
}
loadingGap = true;
try {
final onlyVisible = !KometSettings.viewDeleted.value;
var slice = await loadGapSliceFromDb(
gap.edgeTime,
gap.tailTime,
onlyVisible,
);
if (!isMounted()) return 0;
if (slice.length < gapPageSize) {
final fetched = await messagesModule.fetchHistory(
myId,
chatId,
fromTime: gap.edgeTime,
forward: gapPageSize,
backward: 0,
);
if (!isMounted()) return 0;
if (fetched.isNotEmpty && KometSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
final refreshed = await loadGapSliceFromDb(
gap.edgeTime,
gap.tailTime,
onlyVisible,
);
if (!isMounted()) return 0;
if (refreshed.length <= slice.length) {
if (refreshed.isNotEmpty) mergeMessages(refreshed);
_closeGap(gap);
return refreshed.length;
}
slice = refreshed;
}
if (slice.isEmpty) {
_closeGap(gap);
return 0;
}
mergeMessages(slice);
var edge = slice.first;
for (final m in slice) {
if (m.time > edge.time) edge = m;
}
gap.edgeId = edge.id;
gap.edgeTime = edge.time;
if (edge.time >= gap.tailTime) _closeGap(gap);
return slice.length;
} catch (e) {
logger.e('Error filling history gap: $e');
return 0;
} finally {
loadingGap = false;
}
}
void persistSessionCache() {
if (myId == 0 || messages.isEmpty) return;
if (myId == 0 || messages.isEmpty || hasGap) return;
MessageSessionCache.save(
myId,
chatId,
@@ -119,29 +299,32 @@ class ChatController extends ChangeNotifier {
required void Function() onLoadingStarted,
required void Function(int added) onLoaded,
required void Function(Object error) onError,
int? pageSize,
bool persist = true,
}) async {
if (isLoadingMore || !hasMoreHistory || messages.isEmpty) return;
isLoadingMore = true;
onLoadingStarted();
final size = pageSize ?? historyPageSize;
final oldest = messages.first;
final onlyVisible = !KometSettings.viewDeleted.value;
try {
var older = await loadOlderFromDb(oldest.time, onlyVisible);
var older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size);
if (older.length < historyPageSize) {
if (older.length < size) {
final fetched = await messagesModule.fetchHistory(
myId,
chatId,
fromTime: oldest.time,
count: historyPageSize,
count: size,
);
if (fetched.isNotEmpty) {
if (KometSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
older = await loadOlderFromDb(oldest.time, onlyVisible);
older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size);
}
}
@@ -149,7 +332,7 @@ class ChatController extends ChangeNotifier {
final added = prependOlder(older);
isLoadingMore = false;
if (added == 0) hasMoreHistory = false;
persistSessionCache();
if (persist) persistSessionCache();
onLoaded(added);
} catch (e) {
logger.e('Error loading more history: $e');
@@ -2,11 +2,14 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/online_dot.dart';
class ChatHeaderRow extends StatelessWidget {
final bool glossy;
final bool frosted;
final BackdropKey? backdropKey;
final ColorScheme cs;
final bool embedded;
final int chatId;
@@ -28,6 +31,8 @@ class ChatHeaderRow extends StatelessWidget {
const ChatHeaderRow({
super.key,
required this.glossy,
required this.frosted,
this.backdropKey,
required this.cs,
required this.embedded,
required this.chatId,
@@ -51,6 +56,10 @@ class ChatHeaderRow extends StatelessWidget {
Widget build(BuildContext context) =>
glossy ? _glossyRow(context) : _materialRow(context);
Color? get _pillColor => frosted ? AppFrost.pillTint(cs) : null;
double? get _pillBlur => frosted ? AppFrost.sigma : null;
Widget _glossyRow(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
@@ -62,6 +71,9 @@ class ChatHeaderRow extends StatelessWidget {
width: 56,
height: 56,
child: GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
backdropKey: backdropKey,
onTap: () {
if (embedded) {
onClose?.call();
@@ -83,6 +95,9 @@ class ChatHeaderRow extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
backdropKey: backdropKey,
onTap: onOpenInfo,
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
child: Row(
@@ -168,6 +183,9 @@ class ChatHeaderRow extends StatelessWidget {
),
const SizedBox(width: 8),
GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
backdropKey: backdropKey,
padding: const EdgeInsets.symmetric(horizontal: 2),
child: SizedBox(
height: 56,
@@ -8,6 +8,9 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/core/config/app_chat_chrome.dart';
import 'package:komet/core/config/app_colors.dart';
import 'package:komet/core/config/app_composer_background.dart';
import 'package:komet/core/config/app_composer_style.dart';
import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/screens/chats/chat/upload_status.dart';
import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart';
import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart';
@@ -19,6 +22,9 @@ class ComposerInputBar extends StatelessWidget {
super.key,
required this.chatType,
required this.chrome,
required this.style,
required this.background,
this.backdropKey,
required this.attachAnim,
required this.replyTo,
required this.myId,
@@ -43,6 +49,9 @@ class ComposerInputBar extends StatelessWidget {
final String chatType;
final ChatChromeStyle chrome;
final ComposerStyle style;
final ComposerBackground background;
final BackdropKey? backdropKey;
final Animation<double> attachAnim;
final ValueListenable<CachedMessage?> replyTo;
final int myId;
@@ -104,7 +113,7 @@ class ComposerInputBar extends StatelessWidget {
);
}
return SafeArea(
final bar = SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -124,18 +133,9 @@ class ComposerInputBar extends StatelessWidget {
minHeight: 54,
maxHeight: 180,
),
child: GlossyPill(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
borderRadius: BorderRadius.circular(28),
depth: 8,
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
child: Stack(
child: _fieldSurface(
cs,
Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
@@ -317,14 +317,16 @@ class ComposerInputBar extends StatelessWidget {
builder: (context, videoMode, _) {
final sendMode =
hasText || locked;
final pill = GlossyPill(
color: sendMode
final pill = _actionSurface(
color: _flat
? Colors.transparent
: sendMode
? cs.primary
: recording
? cs.error
: _frost
? AppFrost.inputTint(cs)
: cs.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(27),
onTap: hasText
? onSendText
: locked
@@ -335,7 +337,6 @@ class ComposerInputBar extends StatelessWidget {
onLongPress: hasText
? onScheduleMessage
: null,
depth: 8,
child: SizedBox(
width: 54,
height: 54,
@@ -346,7 +347,13 @@ class ComposerInputBar extends StatelessWidget {
: videoMode
? Symbols.videocam
: Symbols.mic,
color: sendMode
color: _flat
? (sendMode
? cs.primary
: recording
? cs.error
: cs.onSurfaceVariant)
: sendMode
? cs.onPrimary
: recording
? cs.onError
@@ -405,6 +412,72 @@ class ComposerInputBar extends StatelessWidget {
],
),
);
return _barSurface(cs, bar);
}
bool get _flat => style == ComposerStyle.materialYou;
bool get _frost => background == ComposerBackground.frostBlur;
Widget _barSurface(ColorScheme cs, Widget child) {
if (!_flat || background == ComposerBackground.frostBlur) return child;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.surface,
border: Border(top: AppFrost.hairline(cs)),
),
child: child,
);
}
Widget _fieldSurface(ColorScheme cs, Widget child) {
if (_flat) return child;
return GlossyPill(
color: _frost
? AppFrost.inputTint(cs)
: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
blurSigma: _frost ? AppFrost.sigma : null,
backdropKey: backdropKey,
borderRadius: BorderRadius.circular(28),
depth: 8,
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
child: child,
);
}
Widget _actionSurface({
required Color color,
required Widget child,
VoidCallback? onTap,
VoidCallback? onLongPress,
}) {
if (_flat) {
return Material(
color: color,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: onTap == null && onLongPress == null
? child
: InkWell(onTap: onTap, onLongPress: onLongPress, child: child),
);
}
return GlossyPill(
color: color,
blurSigma: _frost ? AppFrost.sigma : null,
backdropKey: backdropKey,
borderRadius: BorderRadius.circular(27),
onTap: onTap,
onLongPress: onLongPress,
depth: 8,
child: child,
);
}
Widget _replyPreview(ColorScheme cs) {
@@ -465,19 +538,19 @@ class ComposerInputBar extends StatelessWidget {
],
),
);
if (chrome != ChatChromeStyle.transparent) return row;
if (_flat && _frost) return row;
if (!_frost && chrome != ChatChromeStyle.transparent) return row;
return ClipRect(
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 34, sigmaY: 34),
filter: ui.ImageFilter.blur(
sigmaX: AppFrost.sigma,
sigmaY: AppFrost.sigma,
),
backdropGroupKey: backdropKey,
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.surface.withValues(alpha: 0.38),
border: Border(
top: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.4),
width: 0.5,
),
),
color: AppFrost.panelTint(cs),
border: Border(top: AppFrost.hairline(cs)),
),
child: row,
),
+211 -108
View File
@@ -15,7 +15,7 @@ import 'package:komet/backend/modules/upload_notification_service.dart';
import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart';
import 'package:komet/frontend/screens/contacts/open_contact_profile.dart';
import 'package:komet/frontend/screens/chats/chat_list_screen.dart';
import 'package:komet/frontend/screens/chats/poll_create_screen.dart';
import 'package:komet/frontend/widgets/animated_text_swap.dart';
@@ -68,6 +68,9 @@ import 'chat/view/shimmer_loading.dart';
import '../../../core/config/app_commands.dart';
import '../../../core/config/app_visual_style.dart';
import '../../../core/config/app_chat_chrome.dart';
import 'package:komet/core/config/app_composer_background.dart';
import 'package:komet/core/config/app_frost.dart';
import 'package:komet/core/config/app_composer_style.dart';
import '../../../core/config/komet_settings.dart';
import '../../../models/attachment.dart';
import '../../../models/sticker.dart';
@@ -110,15 +113,24 @@ class _UnreadSeparatorItem {
class _FrostedPanel extends StatelessWidget {
final Color tint;
final Border? border;
final double sigma;
final BackdropKey? backdropKey;
final Widget child;
const _FrostedPanel({required this.tint, this.border, required this.child});
const _FrostedPanel({
required this.tint,
this.border,
this.sigma = AppFrost.panelSigma,
this.backdropKey,
required this.child,
});
@override
Widget build(BuildContext context) {
return ClipRect(
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24),
filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
backdropGroupKey: backdropKey,
child: DecoratedBox(
decoration: BoxDecoration(color: tint, border: border),
child: child,
@@ -437,6 +449,9 @@ class _ChatScreenState extends State<ChatScreen>
static const double _glossyHeaderHeight = 76.0;
static const double _glossySearchHeight = 58.0;
static const double _pinnedBannerLift = 6.0;
final BackdropKey _barBackdrop = BackdropKey();
final BackdropKey _pillBackdrop = BackdropKey();
bool get _isLoadingMore => _chatController.isLoadingMore;
set _isLoadingMore(bool v) => _chatController.isLoadingMore = v;
bool get _hasMoreHistory => _chatController.hasMoreHistory;
@@ -450,6 +465,12 @@ class _ChatScreenState extends State<ChatScreen>
bool _peerIsBot = false;
ChatWallpaper? _wallpaper;
bool get _composerFrosted =>
AppComposerBackground.current.value == ComposerBackground.frostBlur;
bool get _composerUnderlap =>
AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted;
ChatChromeStyle get _effectiveChrome {
final chrome = AppChatChrome.current.value;
if (_wallpaper != null && chrome == ChatChromeStyle.none) {
@@ -510,6 +531,8 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.addListener(_exitTextSelectionOnScroll);
AppVisualStyle.current.addListener(_onVisualStyleChanged);
AppChatChrome.current.addListener(_onVisualStyleChanged);
AppComposerStyle.current.addListener(_onVisualStyleChanged);
AppComposerBackground.current.addListener(_onVisualStyleChanged);
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
@@ -898,7 +921,7 @@ class _ChatScreenState extends State<ChatScreen>
final ua = _unreadAnchorTime;
return ua != null && _messages.indexWhere((m) => m.time > ua) > 0;
},
maxPages: 80,
maxPages: 15,
);
if (!mounted) return;
if (_unreadAnchorTime == null) _resolveCountBasedAnchor();
@@ -1161,27 +1184,14 @@ class _ChatScreenState extends State<ChatScreen>
void _jumpToPinnedMessage() {
final id = chat?.pinnedMsgId;
final time = chat?.pinnedMsgTime;
if (id == null) return;
unawaited(_openPinnedMessage(id.toString(), time ?? 0));
}
Future<void> _openPinnedMessage(String messageId, int time) async {
if (!_messages.any((m) => m.id == messageId)) {
await _walkHistoryBack(
reached: () => _messages.any((m) => m.id == messageId),
maxPages: 60,
targetTime: time,
);
if (!mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
}
final messageId = id.toString();
if (_messages.any((m) => m.id == messageId)) {
_scrollToLoadedMessage(messageId);
} else {
showCustomNotification(context, 'Сообщение не загружено');
return;
}
setState(_beginTargetNavigation);
unawaited(_runGoToMessage(messageId, chat?.pinnedMsgTime ?? 0));
}
bool _badgeRefreshing = false;
@@ -1269,7 +1279,9 @@ class _ChatScreenState extends State<ChatScreen>
void _maybeLoadMoreHistory() {
if (!_scrollController.hasClients) return;
if (_historyAutoloadSuppressed) return;
if (_isLoading || _isLoadingMore || !_hasMoreHistory) return;
if (_isLoading) return;
_maybeFillGap();
if (_isLoadingMore || !_hasMoreHistory) return;
if (_messages.isEmpty) return;
final pos = _scrollController.position;
if (pos.maxScrollExtent <= 0) return;
@@ -1278,6 +1290,75 @@ class _ChatScreenState extends State<ChatScreen>
}
}
void _maybeFillGap() {
final controller = _chatController;
if (!controller.hasGap || controller.loadingGap) return;
for (final gap in controller.gaps) {
final box = _keyForMessage(gap.edgeId).currentContext?.findRenderObject();
if (box is RenderBox && box.attached) {
unawaited(_fillGapForward(gap));
return;
}
}
}
Future<void> _fillGapForward(HistoryGap gap) async {
final edgeId = gap.edgeId;
final beforeDy = _messageOffsetInList(edgeId);
final added = await _chatController.fillGapForward(gap);
if (!mounted || added == 0) return;
_syncReactionNotifiersFromMessages();
_bumpMessages();
await WidgetsBinding.instance.endOfFrame;
if (!mounted || !_scrollController.hasClients) return;
final afterDy = _messageOffsetInList(edgeId);
if (beforeDy != null && afterDy != null) {
final delta = beforeDy - afterDy;
if (delta.abs() > 0.5) {
final pos = _scrollController.position;
_scrollController.jumpTo(
(pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent),
);
}
}
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
double? _messageOffsetInList(String messageId) {
final listBox = _listKey.currentContext?.findRenderObject();
final box = _keyForMessage(messageId).currentContext?.findRenderObject();
if (listBox is! RenderBox || box is! RenderBox || !box.attached) return null;
return box.localToGlobal(Offset.zero, ancestor: listBox).dy;
}
Future<void> _loadMessageWindow(String messageId, int targetTime) async {
if (targetTime <= 0) {
await _walkHistoryBack(
reached: () => _messages.any((m) => m.id == messageId),
maxPages: 10,
);
return;
}
_historyAutoloadSuppressCount++;
try {
await _chatController.loadMessageWindow(
targetId: messageId,
targetTime: targetTime,
);
} finally {
_historyAutoloadSuppressCount--;
}
if (!mounted) return;
_syncReactionNotifiersFromMessages();
_bumpMessages();
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
Future<void> _walkHistoryBack({
required bool Function() reached,
required int maxPages,
@@ -1294,7 +1375,11 @@ class _ChatScreenState extends State<ChatScreen>
(_messages.isEmpty || _messages.first.time > targetTime)) {
page++;
final before = _messages.isEmpty ? 0 : _messages.first.time;
await _loadMoreHistory(resolveSenderNames: false);
await _loadMoreHistory(
resolveSenderNames: false,
pageSize: ChatController.historyWalkPageSize,
persist: false,
);
if (!mounted) return;
final after = _messages.isEmpty ? 0 : _messages.first.time;
if (after == before) break;
@@ -1303,12 +1388,19 @@ class _ChatScreenState extends State<ChatScreen>
_historyAutoloadSuppressCount--;
}
if (!mounted) return;
_chatController.persistSessionCache();
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
Future<void> _loadMoreHistory({bool resolveSenderNames = true}) async {
Future<void> _loadMoreHistory({
bool resolveSenderNames = true,
int? pageSize,
bool persist = true,
}) async {
await _chatController.loadMoreHistory(
pageSize: pageSize,
persist: persist,
onLoadingStarted: _bumpMessages,
onLoaded: (added) {
if (added > 0) _syncReactionNotifiersFromMessages();
@@ -1411,6 +1503,8 @@ class _ChatScreenState extends State<ChatScreen>
_readMarkTimer?.cancel();
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
AppChatChrome.current.removeListener(_onVisualStyleChanged);
AppComposerStyle.current.removeListener(_onVisualStyleChanged);
AppComposerBackground.current.removeListener(_onVisualStyleChanged);
_composerHeight.dispose();
_pinnedBannerHeight.dispose();
_floatingDateTimer?.cancel();
@@ -1970,6 +2064,9 @@ class _ChatScreenState extends State<ChatScreen>
ComposerInputBar(
chatType: widget.chatType,
chrome: _effectiveChrome,
style: AppComposerStyle.current.value,
background: AppComposerBackground.current.value,
backdropKey: _pillBackdrop,
attachAnim: _attachAnim,
replyTo: _replyTo,
myId: _myId,
@@ -2028,15 +2125,23 @@ class _ChatScreenState extends State<ChatScreen>
],
);
Widget wrapChrome(Widget child) {
if (_composerFrosted) {
if (AppComposerStyle.current.value != ComposerStyle.materialYou) {
return child;
}
return _FrostedPanel(
sigma: AppFrost.sigma,
tint: AppFrost.panelTint(cs),
border: Border(top: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: child,
);
}
if (_effectiveChrome != ChatChromeStyle.blur) return child;
return _FrostedPanel(
tint: cs.surfaceContainerHigh.withValues(alpha: 0.55),
border: Border(
top: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.4),
width: 0.5,
),
),
tint: AppFrost.blurPanelTint(cs),
border: Border(top: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: child,
);
}
@@ -2398,13 +2503,9 @@ class _ChatScreenState extends State<ChatScreen>
: Colors.transparent,
flexibleSpace: chrome == ChatChromeStyle.blur
? _FrostedPanel(
tint: cs.surfaceContainerHigh.withValues(alpha: 0.55),
border: Border(
bottom: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.4),
width: 0.5,
),
),
tint: AppFrost.blurPanelTint(cs),
border: Border(bottom: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: const SizedBox.expand(),
)
: (chrome == ChatChromeStyle.none && !glossy)
@@ -2426,22 +2527,12 @@ class _ChatScreenState extends State<ChatScreen>
),
)
: (chrome == ChatChromeStyle.transparent && !glossy)
? IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
cs.surface.withValues(alpha: 0.72),
cs.surface.withValues(alpha: 0.45),
cs.surface.withValues(alpha: 0.0),
],
stops: const [0.0, 0.62, 1.0],
),
),
child: const SizedBox.expand(),
),
? _FrostedPanel(
sigma: AppFrost.sigma,
tint: AppFrost.panelTint(cs),
border: Border(bottom: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: const SizedBox.expand(),
)
: null,
foregroundColor: cs.onSurface,
@@ -2477,6 +2568,10 @@ class _ChatScreenState extends State<ChatScreen>
offset: Offset(0, -height * 0.4 * t),
child: ChatHeaderRow(
glossy: glossy,
frosted:
glossy &&
chrome == ChatChromeStyle.transparent,
backdropKey: _pillBackdrop,
cs: cs,
embedded: widget.embedded,
chatId: widget.chatId,
@@ -3482,14 +3577,12 @@ class _ChatScreenState extends State<ChatScreen>
void _openSenderProfile(int senderId) {
if (senderId == 0 || senderId == _myId) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: senderId,
initialName: ContactCache.get(senderId),
initialAvatarUrl: ContactCache.getAvatar(senderId),
),
unawaited(
openContactDialogProfile(
context,
contactId: senderId,
name: ContactCache.get(senderId) ?? 'User #$senderId',
avatarUrl: ContactCache.getAvatar(senderId),
),
);
}
@@ -3570,11 +3663,7 @@ class _ChatScreenState extends State<ChatScreen>
if (!mounted) return;
if (!_messages.any((m) => m.id == id)) {
await _walkHistoryBack(
reached: () => _messages.any((m) => m.id == id),
maxPages: 80,
targetTime: targetTime,
);
await _loadMessageWindow(id, targetTime);
if (!mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
@@ -3684,29 +3773,14 @@ class _ChatScreenState extends State<ChatScreen>
Future<void> _openSearchResult(MessageSearchResult result) async {
_closeSearch();
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
if (!_messages.any((m) => m.id == result.id)) {
var guard = 0;
while (mounted &&
guard < 60 &&
_hasMoreHistory &&
!_messages.any((m) => m.id == result.id) &&
(_messages.isEmpty || _messages.first.time > result.time)) {
guard++;
final before = _messages.isEmpty ? 0 : _messages.first.time;
await _loadMoreHistory();
if (!mounted) return;
final after = _messages.isEmpty ? 0 : _messages.first.time;
if (after == before) break;
}
if (!mounted) return;
if (_messages.any((m) => m.id == result.id)) {
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
_scrollToLoadedMessage(result.id);
return;
}
_scrollToLoadedMessage(result.id);
setState(_beginTargetNavigation);
await _runGoToMessage(result.id, result.time);
}
void _scrollToLoadedMessage(
@@ -4129,6 +4203,8 @@ class _ChatScreenState extends State<ChatScreen>
text: pinned.pinnedMsgText,
isPreview: pinned.pinnedMsgIsPreview,
floating: floating,
frosted: _effectiveChrome == ChatChromeStyle.transparent,
backdropKey: _pillBackdrop,
onTap: _jumpToPinnedMessage,
onUnpin: pinned.canPinMessages(_myId)
? () => unawaited(_unpinCurrentMessage())
@@ -4139,6 +4215,11 @@ class _ChatScreenState extends State<ChatScreen>
Widget _buildColorBody() {
final cs = Theme.of(context).colorScheme;
final banner = _buildPinnedBanner(floating: false);
final frosted = _composerFrosted;
final composer = _MeasureSize(
onHeight: (value) => _composerHeight.value = value,
child: _buildComposerArea(context),
);
return Column(
children: [
?banner,
@@ -4151,12 +4232,17 @@ class _ChatScreenState extends State<ChatScreen>
child: ChatWallpaperView(wallpaper: _wallpaper!),
),
Positioned.fill(child: _buildMessagesArea()),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: CommandPanelView(commandPanel: _commandPanel),
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => Positioned(
left: 0,
right: 0,
bottom: frosted ? height : 0,
child: CommandPanelView(commandPanel: _commandPanel),
),
),
if (frosted)
Positioned(left: 0, right: 0, bottom: 0, child: composer),
SearchOverlay(
cs: cs,
searchAnim: _searchAnim,
@@ -4168,7 +4254,7 @@ class _ChatScreenState extends State<ChatScreen>
],
),
),
_buildComposerArea(context),
if (!frosted) composer,
],
);
}
@@ -4391,13 +4477,8 @@ class _ChatScreenState extends State<ChatScreen>
if (index == 0) {
return ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => SizedBox(
height:
AppChatChrome.current.value ==
ChatChromeStyle.color
? 0
: height,
),
builder: (context, height, _) =>
SizedBox(height: _composerUnderlap ? height : 0),
);
}
if (index > items.length) {
@@ -5609,6 +5690,8 @@ class _PinnedMessageBanner extends StatelessWidget {
final VoidCallback onTap;
final VoidCallback? onUnpin;
final bool floating;
final bool frosted;
final BackdropKey? backdropKey;
const _PinnedMessageBanner({
required this.text,
@@ -5616,13 +5699,17 @@ class _PinnedMessageBanner extends StatelessWidget {
required this.onTap,
this.onUnpin,
this.floating = false,
this.frosted = false,
this.backdropKey,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final content = Material(
color: floating
color: frosted
? AppFrost.panelTint(cs)
: floating
? cs.surfaceContainerHigh.withValues(alpha: 0.92)
: cs.surfaceContainerHigh,
borderRadius: floating ? BorderRadius.circular(16) : null,
@@ -5679,16 +5766,32 @@ class _PinnedMessageBanner extends StatelessWidget {
),
);
if (!floating) {
return DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.4),
width: 0.5,
final bottomBorder = Border(bottom: AppFrost.hairline(cs));
if (frosted) {
return ClipRRect(
borderRadius: floating
? BorderRadius.circular(16)
: BorderRadius.zero,
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: AppFrost.sigma,
sigmaY: AppFrost.sigma,
),
backdropGroupKey: backdropKey,
child: DecoratedBox(
decoration: BoxDecoration(
border: floating ? null : bottomBorder,
),
child: content,
),
),
);
}
if (!floating) {
return DecoratedBox(
decoration: BoxDecoration(border: bottomBorder),
child: content,
);
}
+13 -15
View File
@@ -11,7 +11,7 @@ import '../../../core/utils/debouncer.dart';
import '../../../core/utils/names.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/swipe_route.dart';
import '../contacts/contact_profile_screen.dart';
import '../contacts/open_contact_profile.dart';
import 'chat_screen.dart';
class SearchScreen extends StatefulWidget {
@@ -147,13 +147,12 @@ class _SearchScreenState extends State<SearchScreen> {
}
void _openContact(Map<String, dynamic> row) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: row['id'] as int,
initialName: _contactName(row),
initialAvatarUrl: row['base_url'] as String?,
),
unawaited(
openContactDialogProfile(
context,
contactId: row['id'] as int,
name: _contactName(row),
avatarUrl: row['base_url'] as String?,
),
);
}
@@ -166,13 +165,12 @@ class _SearchScreenState extends State<SearchScreen> {
}
void _openPhoneResult(PhoneLookupResult result) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: result.id,
initialName: result.name,
initialAvatarUrl: result.avatarUrl,
),
unawaited(
openContactDialogProfile(
context,
contactId: result.id,
name: result.name ?? 'User #${result.id}',
avatarUrl: result.avatarUrl,
),
);
}
@@ -1,420 +0,0 @@
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 '../../../l10n/app_localizations.dart';
import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/swipe_route.dart';
import '../chats/chat_screen.dart';
class ContactProfileScreen extends StatefulWidget {
final int contactId;
final String? initialName;
final String? initialAvatarUrl;
const ContactProfileScreen({
super.key,
required this.contactId,
this.initialName,
this.initialAvatarUrl,
});
@override
State<ContactProfileScreen> createState() => _ContactProfileScreenState();
}
class _ContactProfileScreenState extends State<ContactProfileScreen> {
bool _loading = true;
ContactInfo? _contact;
int? _seenTime;
int _presenceStatus = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final contactFuture = ContactInfoFetch.get(widget.contactId);
final presenceFuture = PresenceFetch.get(widget.contactId);
final contact = await contactFuture;
final presence = await presenceFuture;
if (!mounted) return;
if (contact != null) {
_contact = contact;
}
if (presence != null) {
_seenTime = presence['seen'] as int?;
_presenceStatus = (presence['status'] as int?) ?? 0;
}
} catch (e) {
if (mounted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.contactProfileLoadError(e.toString()),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _displayName() {
return _contact?.displayName ??
widget.initialName ??
'User #${widget.contactId}';
}
String? _avatarUrl() {
return _contact?.avatarUrl ?? widget.initialAvatarUrl;
}
Set<String> _options() {
return _contact?.options.toSet() ?? const {};
}
bool get _isBot => _options().contains('BOT');
bool get _isVerified => _options().contains('OFFICIAL');
String _subtitle() {
final l10n = AppLocalizations.of(context)!;
if (_isBot) return l10n.contactProfileBot;
if (_presenceStatus == 1) return l10n.contactProfileOnline;
if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive;
if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!);
return '';
}
Future<void> _openChat() async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final existing = await AppDatabase.findDialogChatByParticipant(
accountId,
widget.contactId,
);
final chatId = existing ?? (accountId ^ widget.contactId);
if (!mounted) return;
pushSwipeable(
context,
(_) => ChatScreen(
chatId: chatId,
name: _displayName(),
imageUrl: _avatarUrl() ?? '',
chatType: 'DIALOG',
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
floatingActionButton: const ConnectionSpinner(),
body: SafeArea(
child: _loading
? const Center(child: CircularProgressIndicator())
: _buildBody(cs),
),
);
}
Widget _buildBody(ColorScheme cs) {
return CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: Colors.transparent,
elevation: 0,
floating: true,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: widget.contactId,
name: _displayName(),
currentAvatarUrl: _avatarUrl(),
),
child: KometAvatar(
name: _displayName(),
imageUrl: _avatarUrl(),
size: 96,
fontSize: 36,
),
),
const SizedBox(height: 14),
_buildNameRow(cs),
const SizedBox(height: 4),
Text(
_subtitle(),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 20),
_buildActions(cs),
const SizedBox(height: 16),
_buildInfoCard(cs),
const SizedBox(height: 40),
],
),
),
),
],
);
}
Widget _buildNameRow(ColorScheme cs) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
_displayName(),
style: TextStyle(
color: cs.onSurface,
fontSize: 22,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (_isVerified) ...[
const SizedBox(width: 6),
Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1),
],
],
);
}
Widget _buildActions(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final actions = <({IconData icon, String label, VoidCallback? onTap})>[
(
icon: Symbols.chat_bubble,
label: l10n.contactProfileActionChat,
onTap: _openChat,
),
(
icon: Symbols.notifications,
label: l10n.contactProfileActionSound,
onTap: null,
),
if (!_isBot)
(icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null),
];
return Row(
children: [
for (var i = 0; i < actions.length; i++) ...[
Expanded(
child: GestureDetector(
onTap: actions[i].onTap,
child: GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
padding: const EdgeInsets.symmetric(vertical: 12),
depth: 6,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(actions[i].icon, color: cs.primary, size: 22),
const SizedBox(height: 4),
Text(
actions[i].label,
style: TextStyle(color: cs.onSurface, fontSize: 12),
),
],
),
),
),
),
if (i < actions.length - 1) const SizedBox(width: 8),
],
],
);
}
Widget _buildInfoCard(ColorScheme cs) {
final c = _contact;
if (c == null) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
final rows = <Widget>[];
final phoneStr = formatPhone(c.raw['phone']);
if (phoneStr != null) {
rows.add(
_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr),
);
}
final country = c.raw['country'] as String?;
if (country != null && country.isNotEmpty) {
rows.add(
_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country),
);
}
final genderStr = formatGender(c.raw['gender']);
if (genderStr != null) {
rows.add(
_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr),
);
}
final regTime = c.raw['registrationTime'] as int?;
if (regTime != null && regTime > 0) {
rows.add(
_infoRow(
cs,
Symbols.event,
l10n.contactProfileInfoRegistration,
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)),
),
);
}
final updateTime = c.raw['updateTime'] as int?;
if (updateTime != null && updateTime > 0) {
rows.add(
_infoRow(
cs,
Symbols.update,
l10n.contactProfileInfoUpdated,
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)),
),
);
}
final accountStatus = c.raw['accountStatus'];
if (accountStatus is int && accountStatus != 0) {
rows.add(
_infoRow(
cs,
Symbols.account_circle,
l10n.contactProfileInfoAccountStatus,
accountStatus.toString(),
),
);
}
final desc = (c.raw['description'] as String?)?.trim();
if (desc != null && desc.isNotEmpty) {
rows.add(
_infoRow(
cs,
Symbols.info,
l10n.contactProfileInfoDescription,
desc,
multiline: true,
),
);
}
final link = c.raw['link'] as String?;
if (link != null && link.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.link, l10n.contactProfileInfoLink, link));
}
final webApp = c.raw['webApp'] as String?;
if (webApp != null && webApp.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.web, 'Web app', webApp));
}
final opts = _options();
if (opts.isNotEmpty) {
rows.add(
_infoRow(
cs,
Symbols.label,
l10n.contactProfileInfoFlags,
opts.join(', '),
multiline: true,
),
);
}
rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString()));
if (rows.isEmpty) return const SizedBox.shrink();
return GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
depth: 6,
child: SizedBox(
width: double.infinity,
child: Column(
children: [
for (var i = 0; i < rows.length; i++) ...[
if (i > 0)
Divider(
height: 1,
color: cs.outlineVariant.withValues(alpha: 0.3),
),
rows[i],
],
],
),
),
);
}
Widget _infoRow(
ColorScheme cs,
IconData icon,
String label,
String value, {
bool multiline = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 20),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(color: cs.onSurface, fontSize: 14),
maxLines: multiline ? null : 1,
overflow: multiline ? null : TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
@@ -13,32 +13,7 @@ import '../../widgets/connection_status.dart';
import '../../widgets/sheet_helpers.dart';
import '../chats/chat_info_screen.dart';
import 'nfc_exchange_sheet.dart';
Future<void> openContactDialogProfile(
BuildContext context, {
required int contactId,
required String name,
String? avatarUrl,
}) async {
final accountId = await TokenStorage.getActiveAccountId();
final existing = accountId == null
? null
: await AppDatabase.findDialogChatByParticipant(accountId, contactId);
final chatId = existing ?? ((accountId ?? 0) ^ contactId);
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: chatId,
name: name,
imageUrl: avatarUrl ?? '',
chatType: 'DIALOG',
dialogPeerId: contactId,
),
),
);
}
import 'open_contact_profile.dart';
class ContactsTab extends StatefulWidget {
const ContactsTab({super.key});
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
import '../chats/chat_info_screen.dart';
Future<void> openContactDialogProfile(
BuildContext context, {
required int contactId,
required String name,
String? avatarUrl,
}) async {
final accountId = await TokenStorage.getActiveAccountId();
final existing = accountId == null
? null
: await AppDatabase.findDialogChatByParticipant(accountId, contactId);
final chatId = existing ?? ((accountId ?? 0) ^ contactId);
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: chatId,
name: name,
imageUrl: avatarUrl ?? '',
chatType: 'DIALOG',
dialogPeerId: contactId,
),
),
);
}
@@ -10,6 +10,8 @@ import '../../../core/config/app_bubble_shape.dart';
import '../../../core/config/app_pill_gradient.dart';
import '../../../core/config/app_visual_style.dart';
import '../../../core/config/app_chat_chrome.dart';
import '../../../core/config/app_composer_background.dart';
import '../../../core/config/app_composer_style.dart';
import '../../../core/utils/bubble_radius.dart';
import '../../../core/utils/debouncer.dart';
import '../../../core/utils/haptics.dart';
@@ -119,6 +121,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
const SizedBox(height: 12),
const _ChatChromeCard(),
const SizedBox(height: 12),
const _ComposerBarCard(),
const SizedBox(height: 12),
const _GradientToggleCard(),
],
),
@@ -253,6 +257,90 @@ class _ChatChromeCard extends StatelessWidget {
}
}
class _ComposerBarCard extends StatelessWidget {
const _ComposerBarCard();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
depth: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.appearanceComposerTitle,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.appearanceComposerSubtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
ValueListenableBuilder<ComposerStyle>(
valueListenable: AppComposerStyle.current,
builder: (context, current, _) {
return SegmentedButton<ComposerStyle>(
segments: [
ButtonSegment(
value: ComposerStyle.glossy,
label: Text(l10n.appearanceVisualStyleGlossy),
),
ButtonSegment(
value: ComposerStyle.materialYou,
label: Text(l10n.appearanceVisualStyleMaterialYou),
),
],
selected: {current},
onSelectionChanged: (set) {
if (set.isNotEmpty) {
Haptics.selection();
AppComposerStyle.save(set.first);
}
},
);
},
),
const SizedBox(height: 10),
ValueListenableBuilder<ComposerBackground>(
valueListenable: AppComposerBackground.current,
builder: (context, current, _) {
return SegmentedButton<ComposerBackground>(
segments: [
ButtonSegment(
value: ComposerBackground.standard,
label: Text(l10n.appearanceComposerBackgroundStandard),
),
ButtonSegment(
value: ComposerBackground.frostBlur,
label: Text(l10n.appearanceComposerBackgroundFrost),
),
],
selected: {current},
onSelectionChanged: (set) {
if (set.isNotEmpty) {
Haptics.selection();
AppComposerBackground.save(set.first);
}
},
);
},
),
],
),
);
}
}
class _GradientToggleCard extends StatelessWidget {
const _GradientToggleCard();
@@ -40,7 +40,7 @@ class _CustomizationSectionState extends State<CustomizationSection> {
builder: (context) => const ThemeSettingsScreen(),
),
_CustomizationCategory(
icon: Symbols.palette,
icon: Symbols.styler,
title: 'Внешний вид',
builder: (context) => const AppearanceScreen(),
),
+29 -1
View File
@@ -1,3 +1,5 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../core/config/app_pill_gradient.dart';
@@ -90,6 +92,8 @@ class GlossyPill extends StatelessWidget {
final double depth;
final bool elevated;
final BorderSide? borderSide;
final double? blurSigma;
final BackdropKey? backdropKey;
const GlossyPill({
super.key,
@@ -102,9 +106,14 @@ class GlossyPill extends StatelessWidget {
this.depth = 10,
this.elevated = false,
this.borderSide,
this.blurSigma,
this.backdropKey,
}) : borderRadius =
borderRadius ?? const BorderRadius.all(Radius.circular(100));
double? _sigmaFor(Color base) =>
blurSigma != null && base.a < 1 ? blurSigma : null;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<VisualStyle>(
@@ -123,7 +132,7 @@ class GlossyPill extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
final base = color ?? cs.surfaceContainerHigh;
final content = Padding(padding: padding, child: child);
return Material(
final material = Material(
color: base,
elevation: elevated ? 3 : 0,
shadowColor: Colors.black.withValues(alpha: 0.4),
@@ -137,12 +146,23 @@ class GlossyPill extends StatelessWidget {
? content
: InkWell(onTap: onTap, onLongPress: onLongPress, child: content),
);
final sigma = _sigmaFor(base);
if (sigma == null) return material;
return ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
backdropGroupKey: backdropKey,
child: material,
),
);
}
Widget _glossy(BuildContext context, bool gradient) {
final cs = Theme.of(context).colorScheme;
final base = color ?? cs.surfaceContainerHigh;
final content = Padding(padding: padding, child: child);
final sigma = _sigmaFor(base);
return RepaintBoundary(
child: DecoratedBox(
@@ -158,6 +178,14 @@ class GlossyPill extends StatelessWidget {
child: Stack(
fit: StackFit.passthrough,
children: [
if (sigma != null)
Positioned.fill(
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
backdropGroupKey: backdropKey,
child: const SizedBox.expand(),
),
),
if (gradient) ...[
Positioned.fill(
child: IgnorePointer(
+9 -8
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../backend/modules/chats.dart';
@@ -6,7 +8,7 @@ import '../../core/links/max_link.dart';
import '../../core/storage/app_database.dart';
import '../../main.dart';
import '../screens/chats/chat_screen.dart';
import '../screens/contacts/contact_profile_screen.dart';
import '../screens/contacts/open_contact_profile.dart';
import 'call_link_handler.dart';
import 'confirm_dialog.dart';
import 'custom_notification.dart';
@@ -75,13 +77,12 @@ void _openContact(BuildContext context, Map<dynamic, dynamic> contact) {
showCustomNotification(context, 'Не удалось открыть профиль');
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: id,
initialName: _contactName(contact),
initialAvatarUrl: contact['baseUrl'] as String?,
),
unawaited(
openContactDialogProfile(
context,
contactId: id,
name: _contactName(contact),
avatarUrl: contact['baseUrl'] as String?,
),
);
}
@@ -494,19 +494,19 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
final hasText =
widget.messageText != null && widget.messageText!.isNotEmpty;
return <_Action>[
if (widget.onReply != null)
_Action(Symbols.reply, l10n.msgActionsReply, _reply),
if (widget.onForward != null)
_Action(Symbols.forward, l10n.msgActionsForward, _forward),
if (hasText) _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy),
if (widget.isMe && widget.onEdit != null)
_Action(Symbols.edit, l10n.msgActionsEdit, _edit),
if (widget.onReply != null)
_Action(Symbols.reply, l10n.msgActionsReply, _reply),
if (widget.onPin != null)
_Action(
widget.isPinned ? Symbols.keep_off : Symbols.push_pin,
widget.isPinned ? l10n.msgActionsUnpin : l10n.msgActionsPin,
_pin,
),
if (widget.onForward != null)
_Action(Symbols.forward, l10n.msgActionsForward, _forward),
if (widget.onMarkUnread != null)
_Action(
Symbols.mark_chat_unread,
+5 -1
View File
@@ -295,7 +295,11 @@
"appearanceChatChromeColor": "Color",
"appearanceChatChromeBlur": "Blur",
"appearanceChatChromeNone": "None",
"appearanceChatChromeTransparent": "Clear",
"appearanceChatChromeTransparent": "Frost blur",
"appearanceComposerTitle": "Input bar",
"appearanceComposerSubtitle": "Style and background of the message input bar",
"appearanceComposerBackgroundStandard": "Default",
"appearanceComposerBackgroundFrost": "Frost blur",
"appearanceGradientTitle": "Gradient",
"appearanceGradientSubtitle": "Depth and highlights in Glossy capsules",
"appearanceAccentColorTitle": "Accent color",
+25 -1
View File
@@ -1559,9 +1559,33 @@ abstract class AppLocalizations {
/// No description provided for @appearanceChatChromeTransparent.
///
/// In en, this message translates to:
/// **'Clear'**
/// **'Frost blur'**
String get appearanceChatChromeTransparent;
/// No description provided for @appearanceComposerTitle.
///
/// In en, this message translates to:
/// **'Input bar'**
String get appearanceComposerTitle;
/// No description provided for @appearanceComposerSubtitle.
///
/// In en, this message translates to:
/// **'Style and background of the message input bar'**
String get appearanceComposerSubtitle;
/// No description provided for @appearanceComposerBackgroundStandard.
///
/// In en, this message translates to:
/// **'Default'**
String get appearanceComposerBackgroundStandard;
/// No description provided for @appearanceComposerBackgroundFrost.
///
/// In en, this message translates to:
/// **'Frost blur'**
String get appearanceComposerBackgroundFrost;
/// No description provided for @appearanceGradientTitle.
///
/// In en, this message translates to:
+14 -1
View File
@@ -775,7 +775,20 @@ class AppLocalizationsEn extends AppLocalizations {
String get appearanceChatChromeNone => 'None';
@override
String get appearanceChatChromeTransparent => 'Clear';
String get appearanceChatChromeTransparent => 'Frost blur';
@override
String get appearanceComposerTitle => 'Input bar';
@override
String get appearanceComposerSubtitle =>
'Style and background of the message input bar';
@override
String get appearanceComposerBackgroundStandard => 'Default';
@override
String get appearanceComposerBackgroundFrost => 'Frost blur';
@override
String get appearanceGradientTitle => 'Gradient';
+13 -1
View File
@@ -778,7 +778,19 @@ class AppLocalizationsRu extends AppLocalizations {
String get appearanceChatChromeNone => 'Нет';
@override
String get appearanceChatChromeTransparent => 'Прозр.';
String get appearanceChatChromeTransparent => 'Frost blur';
@override
String get appearanceComposerTitle => 'Вид панели ввода';
@override
String get appearanceComposerSubtitle => 'Стиль и фон панели ввода сообщений';
@override
String get appearanceComposerBackgroundStandard => 'Default';
@override
String get appearanceComposerBackgroundFrost => 'Frost blur';
@override
String get appearanceGradientTitle => 'Градиент';
+5 -1
View File
@@ -260,7 +260,11 @@
"appearanceChatChromeColor": "Цвет",
"appearanceChatChromeBlur": "Блюр",
"appearanceChatChromeNone": "Нет",
"appearanceChatChromeTransparent": "Прозр.",
"appearanceChatChromeTransparent": "Frost blur",
"appearanceComposerTitle": "Вид панели ввода",
"appearanceComposerSubtitle": "Стиль и фон панели ввода сообщений",
"appearanceComposerBackgroundStandard": "Default",
"appearanceComposerBackgroundFrost": "Frost blur",
"appearanceGradientTitle": "Градиент",
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
"appearanceAccentColorTitle": "Акцентный цвет",
+6
View File
@@ -38,6 +38,8 @@ import 'core/config/app_media_cache.dart';
import 'core/config/app_pill_gradient.dart';
import 'core/config/app_visual_style.dart';
import 'core/config/app_chat_chrome.dart';
import 'core/config/app_composer_background.dart';
import 'core/config/app_composer_style.dart';
import 'core/config/app_wallpaper_tint.dart';
import 'core/storage/chat_wallpaper_store.dart';
import 'core/utils/wallpaper_seed.dart';
@@ -190,6 +192,8 @@ void main(List<String> args) async {
final pillGradientFuture = AppPillGradient.load();
final visualStyleFuture = AppVisualStyle.load();
final chatChromeFuture = AppChatChrome.load();
final composerStyleFuture = AppComposerStyle.load();
final composerBackgroundFuture = AppComposerBackground.load();
final wallpaperTintFuture = AppWallpaperTint.load();
final themeScheduleFuture = AppThemeSchedule.load();
final messageActionsFuture = AppMessageActionsStyle.load();
@@ -241,6 +245,8 @@ void main(List<String> args) async {
pillGradientFuture,
visualStyleFuture,
chatChromeFuture,
composerStyleFuture,
composerBackgroundFuture,
wallpaperTintFuture,
themeScheduleFuture,
messageActionsFuture,