feat: glossy стиль, возможность выбора frost blur. fix: переход к закрепленному сообщению
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -260,7 +260,11 @@
|
||||
"appearanceChatChromeColor": "Цвет",
|
||||
"appearanceChatChromeBlur": "Блюр",
|
||||
"appearanceChatChromeNone": "Нет",
|
||||
"appearanceChatChromeTransparent": "Прозр.",
|
||||
"appearanceChatChromeTransparent": "Frost blur",
|
||||
"appearanceComposerTitle": "Вид панели ввода",
|
||||
"appearanceComposerSubtitle": "Стиль и фон панели ввода сообщений",
|
||||
"appearanceComposerBackgroundStandard": "Default",
|
||||
"appearanceComposerBackgroundFrost": "Frost blur",
|
||||
"appearanceGradientTitle": "Градиент",
|
||||
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
|
||||
"appearanceAccentColorTitle": "Акцентный цвет",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user