закрепление и мьют чатов

This commit is contained in:
Jganenok
2026-05-17 19:49:29 +07:00
parent 3aa6cdb52c
commit 4987ae30ab
9 changed files with 601 additions and 148 deletions
@@ -183,11 +183,10 @@ class _ChatListScreenState extends State<ChatListScreen>
return _DeleteKind.blocked;
}
_DeleteKind? _selectionDeleteCategory() {
_DeleteKind? _selectionDeleteCategoryFor(List<CachedChat> selected) {
if (_sessionState != SessionState.online) return null;
final myId = _profile?.id;
if (myId == null) return null;
final selected = _selectedChatObjects();
if (selected.isEmpty) return null;
final cats = selected.map((c) => _categorizeChat(c, myId)).toSet();
if (cats.contains(_DeleteKind.blocked)) return null;
@@ -195,6 +194,47 @@ class _ChatListScreenState extends State<ChatListScreen>
return cats.single;
}
Future<void> _onPinTap() async {
final selected = _selectedChatObjects();
if (selected.isEmpty) return;
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
final err = await ChatsModule.togglePin(
api,
chatIds: selected.map((c) => c.id).toList(),
pin: !anyPinned,
);
if (!mounted) return;
if (err != null) showCustomNotification(context, err);
_clearSelection();
}
Future<void> _onMuteTap() async {
final selected = _selectedChatObjects();
if (selected.isEmpty) return;
final anyMuted = selected.any((c) => c.isMuted);
final targetDDU = anyMuted ? ChatsModule.muteOff : ChatsModule.muteForever;
final errors = <String>[];
for (final c in selected) {
final err = await ChatsModule.setChatMute(
api,
chatId: c.id,
dontDisturbUntil: targetDDU,
);
if (err != null) errors.add(err);
}
if (!mounted) return;
if (errors.isNotEmpty) {
showCustomNotification(
context,
errors.length == 1
? errors.first
: 'Не удалось изменить ${errors.length} чат(ов): ${errors.first}',
);
}
_clearSelection();
}
Future<void> _onDeleteTap() async {
final selectedBefore = _selectedChatObjects();
if (selectedBefore.isEmpty) return;
@@ -1328,7 +1368,7 @@ class _ChatListScreenState extends State<ChatListScreen>
avatar ?? "",
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
isMuted: chat.isMuted,
isVerified: isVerified,
isPinned: isPinned,
chatType: "DIALOG",
@@ -1358,7 +1398,7 @@ class _ChatListScreenState extends State<ChatListScreen>
: '',
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
isMuted: chat.isMuted,
isVerified: chat.isOfficial,
isPinned: isPinned,
chatType: chat.type,
@@ -1803,37 +1843,53 @@ class _ChatListScreenState extends State<ChatListScreen>
),
],
),
child: Row(
children: [
IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: _clearSelection,
),
const SizedBox(width: 8),
Text(
_selectedChats.length.toString(),
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (_selectionDeleteCategory() != null)
child: Builder(builder: (_) {
final selected = _selectedChatObjects();
final deleteCategory = _selectionDeleteCategoryFor(selected);
final anyMuted = selected.any((c) => c.isMuted);
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
return Row(
children: [
IconButton(
icon: Icon(Symbols.delete, color: cs.onSurface),
onPressed: _onDeleteTap,
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: _clearSelection,
),
IconButton(
icon: Icon(Symbols.archive, color: cs.onSurface),
onPressed: () {},
),
IconButton(
icon: Icon(Symbols.volume_off, color: cs.onSurface),
onPressed: () {},
),
],
),
const SizedBox(width: 8),
Text(
_selectedChats.length.toString(),
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (deleteCategory != null)
IconButton(
icon: Icon(Symbols.delete, color: cs.onSurface),
onPressed: _onDeleteTap,
),
IconButton(
icon: Icon(Symbols.archive, color: cs.onSurface),
onPressed: () {},
),
IconButton(
icon: Icon(
anyPinned ? Symbols.keep_off : Symbols.keep,
color: cs.onSurface,
),
onPressed: selected.isEmpty ? null : _onPinTap,
),
IconButton(
icon: Icon(
anyMuted ? Symbols.volume_up : Symbols.volume_off,
color: cs.onSurface,
),
onPressed: selected.isEmpty ? null : _onMuteTap,
),
],
);
}),
),
),
],
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_bubble_behavior.dart';
import '../../../core/config/app_bubble_shape.dart';
import '../../../core/utils/bubble_radius.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
@@ -70,6 +72,11 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
AppBubbleShape.save(style);
}
void _onBehaviorChanged(BubbleBehavior behavior) {
Haptics.selection();
AppBubbleBehavior.save(behavior);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -98,6 +105,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
),
const SizedBox(height: 12),
_BubbleShapeCard(onChanged: _onStyleChanged),
const SizedBox(height: 12),
_BubbleBehaviorCard(onChanged: _onBehaviorChanged),
],
),
),
@@ -168,55 +177,85 @@ class _PreviewSectionState extends State<_PreviewSection> {
class _ChatPreview extends StatelessWidget {
const _ChatPreview();
static const _messages = <_PreviewMsg>[
_PreviewMsg('Привет!', true, true, false),
_PreviewMsg('Как тебе?', true, false, true),
_PreviewMsg('Привет!', false, true, false),
_PreviewMsg('хм...', false, false, false),
_PreviewMsg('Вполне неплохо!', false, false, true),
];
BorderRadius _radiusFor(
_PreviewMsg msg,
BubbleStyle style,
BubbleBehavior behavior,
) {
return computeBubbleRadius(
isMe: msg.isMe,
isTop: msg.isTop,
isBottom: msg.isBottom,
style: style,
behavior: behavior,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ValueListenableBuilder<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, style, _) => Container(
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PreviewBubble(text: 'Как тебе?', isMe: true, style: style),
const SizedBox(height: 6),
_PreviewBubble(text: 'отлично выглядит!', isMe: false, style: style),
],
),
return ListenableBuilder(
listenable: Listenable.merge(
[AppBubbleShape.current, AppBubbleBehavior.current],
),
builder: (context, _) {
final style = AppBubbleShape.current.value;
final behavior = AppBubbleBehavior.current.value;
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < _messages.length; i++) ...[
if (i > 0)
SizedBox(height: _messages[i].isTop ? 8 : 2),
_PreviewBubble(
text: _messages[i].text,
isMe: _messages[i].isMe,
radius: _radiusFor(_messages[i], style, behavior),
),
],
],
),
);
},
);
}
}
class _PreviewMsg {
final String text;
final bool isMe;
final bool isTop;
final bool isBottom;
const _PreviewMsg(this.text, this.isMe, this.isTop, this.isBottom);
}
class _PreviewBubble extends StatelessWidget {
final String text;
final bool isMe;
final BubbleStyle style;
final BorderRadius radius;
const _PreviewBubble({
required this.text,
required this.isMe,
required this.style,
required this.radius,
});
BorderRadius get _radius {
const big = Radius.circular(20);
const small = Radius.circular(4);
final outside = style == BubbleStyle.mobile ? big : small;
return BorderRadius.only(
topLeft: isMe ? outside : big,
topRight: isMe ? big : outside,
bottomLeft: isMe ? outside : big,
bottomRight: isMe ? big : outside,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -230,7 +269,7 @@ class _PreviewBubble extends StatelessWidget {
child: Container(
constraints: const BoxConstraints(maxWidth: 220),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(color: bg, borderRadius: _radius),
decoration: BoxDecoration(color: bg, borderRadius: radius),
child: Text(
text,
style: TextStyle(color: fg, fontSize: 15, height: 1.3),
@@ -444,6 +483,67 @@ class _BubbleShapeCard extends StatelessWidget {
}
}
class _BubbleBehaviorCard extends StatelessWidget {
final ValueChanged<BubbleBehavior> onChanged;
const _BubbleBehaviorCard({required this.onChanged});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Поведение сообщения',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Меняется ли форма пузыря по соседям в группе',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
ValueListenableBuilder<BubbleBehavior>(
valueListenable: AppBubbleBehavior.current,
builder: (context, current, _) {
return SegmentedButton<BubbleBehavior>(
segments: const [
ButtonSegment(
value: BubbleBehavior.mutable,
label: Text('Изменяемая'),
icon: Icon(Symbols.auto_fix),
),
ButtonSegment(
value: BubbleBehavior.immutable,
label: Text('Неизменяемая'),
icon: Icon(Symbols.lock),
),
],
selected: {current},
onSelectionChanged: (set) {
if (set.isNotEmpty) onChanged(set.first);
},
);
},
),
],
),
),
);
}
}
class _HueStripPicker extends StatelessWidget {
final Color color;
final ValueChanged<Color> onChanged;
+20 -65
View File
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart';
import '../../core/utils/bubble_radius.dart';
import '../../core/utils/haptics.dart';
import '../../models/attachment.dart';
@@ -196,71 +198,21 @@ class MessageBubble extends StatelessWidget {
BorderRadius _borderRadiusFor(
BubbleStyle bubbleStyle,
BubbleBehavior bubbleBehavior,
BubbleShape shape,
bool hasPhotoWithCaption,
bool hasMultiplePhotosNoCaption,
) {
final outsideRadius =
bubbleStyle == BubbleStyle.mobile ? _bigRadius : _smallRadius;
if (hasPhotoWithCaption &&
(shape == BubbleShape.singleTop ||
shape == BubbleShape.singleMiddle ||
shape == BubbleShape.singleBottom)) {
return BorderRadius.only(
topLeft: _bigRadius,
topRight: _bigRadius,
bottomLeft: isMe ? _bigRadius : _smallRadius,
bottomRight: _smallRadius,
);
}
if (hasMultiplePhotosNoCaption &&
(shape == BubbleShape.singleBottom ||
shape == BubbleShape.singleMiddle)) {
return BorderRadius.only(
topLeft: isMe ? _bigRadius : _smallRadius,
topRight: _smallRadius,
bottomLeft: isMe ? _bigRadius : _smallRadius,
bottomRight: isMe ? _smallRadius : _bigRadius,
);
}
Radius cornerTL = isMe ? outsideRadius : _bigRadius;
Radius cornerTR = isMe ? _bigRadius : outsideRadius;
Radius cornerBL = isMe ? outsideRadius : _bigRadius;
Radius cornerBR = isMe ? _bigRadius : outsideRadius;
switch (shape) {
case BubbleShape.singleTop:
if (isMe) {
cornerBR = _smallRadius;
} else {
cornerBL = _smallRadius;
}
case BubbleShape.singleBottom:
if (isMe) {
cornerTR = _smallRadius;
} else {
cornerTL = _smallRadius;
}
case BubbleShape.singleMiddle:
break;
case BubbleShape.groupedMiddle:
if (isMe) {
cornerTR = _smallRadius;
cornerBR = _smallRadius;
} else {
cornerTL = _smallRadius;
cornerBL = _smallRadius;
}
}
return BorderRadius.only(
topLeft: cornerTL,
topRight: cornerTR,
bottomLeft: cornerBL,
bottomRight: cornerBR,
final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
return computeBubbleRadius(
isMe: isMe,
isTop: isTop,
isBottom: isBottom,
style: bubbleStyle,
behavior: bubbleBehavior,
hasPhotoWithCaption: hasPhotoWithCaption,
hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption,
);
}
@@ -352,9 +304,11 @@ class MessageBubble extends StatelessWidget {
radius: 15,
backgroundColor: Color(0x00000000),
),
ValueListenableBuilder<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, bubbleStyle, child) {
ListenableBuilder(
listenable: Listenable.merge(
[AppBubbleShape.current, AppBubbleBehavior.current],
),
builder: (context, child) {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
@@ -364,7 +318,8 @@ class MessageBubble extends StatelessWidget {
? cs.primaryContainer
: cs.surfaceContainerHighest,
borderRadius: _borderRadiusFor(
bubbleStyle,
AppBubbleShape.current.value,
AppBubbleBehavior.current.value,
shape,
hasPhotoCap,
hasMultiPhotos,