Files
Qlyra/lib/frontend/screens/chats/chat/view/chat_header.dart
T

601 lines
19 KiB
Dart

import 'dart:async';
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/core/config/app_stories.dart';
import 'package:komet/core/utils/haptics.dart';
import 'package:komet/frontend/screens/stories/story_owner_info.dart';
import 'package:komet/frontend/screens/stories/story_ring.dart';
import 'package:komet/frontend/screens/stories/story_viewer_screen.dart';
import 'package:komet/frontend/widgets/encryption_lock_badge.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/online_dot.dart';
import 'package:komet/frontend/widgets/profile_hero.dart';
import 'package:komet/main.dart' show storiesModule;
import 'package:komet/models/story.dart';
import '../../../../../core/config/app_fonts.dart';
class ChatHeaderRow extends StatelessWidget {
final bool glossy;
final bool frosted;
final bool backdropVisible;
final bool liquid;
final BackdropKey? backdropKey;
final ColorScheme cs;
final bool embedded;
final int chatId;
final Object heroTag;
final String name;
final String imageUrl;
final String chatType;
final bool isOfficial;
final bool encrypted;
final int myId;
final ValueListenable<String> headerStatus;
final ValueListenable<int> scheduledCount;
final ValueListenable<int> otherUnread;
final bool showCall;
final VoidCallback? onClose;
final VoidCallback onOpenInfo;
final VoidCallback onOpenScheduled;
final VoidCallback onCall;
final void Function(BuildContext) onMenu;
const ChatHeaderRow({
super.key,
required this.glossy,
required this.frosted,
this.backdropVisible = true,
this.liquid = false,
this.backdropKey,
required this.cs,
required this.embedded,
required this.chatId,
required this.heroTag,
required this.name,
required this.imageUrl,
required this.chatType,
required this.isOfficial,
this.encrypted = false,
required this.myId,
required this.headerStatus,
required this.scheduledCount,
required this.otherUnread,
required this.showCall,
required this.onClose,
required this.onOpenInfo,
required this.onOpenScheduled,
required this.onCall,
required this.onMenu,
});
@override
Widget build(BuildContext context) =>
glossy ? _glossyRow(context) : _materialRow(context);
Color? get _pillColor => frosted || liquid ? AppFrost.glassTint(cs) : null;
double? get _pillBlur =>
frosted && !liquid && backdropVisible ? AppFrost.sigma : null;
Widget _glossyRow(BuildContext context) {
final nameStyle = TextStyle(
color: cs.onSurface,
fontSize: 17,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
);
return Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
child: Row(
children: [
_backWithBadge(
cs,
SizedBox(
width: 56,
height: 56,
child: GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
liquid: liquid,
backdropKey: backdropKey,
onTap: () {
if (embedded) {
onClose?.call();
} else {
Navigator.pop(context);
}
},
child: Center(
child: Icon(
embedded ? Symbols.close : Symbols.arrow_back,
color: cs.onSurface,
weight: 500,
size: 24,
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
liquid: liquid,
backdropKey: backdropKey,
onTap: onOpenInfo,
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
child: Row(
children: [
_withOnlineDot(
cs,
_heroAvatar(
44,
(d) => imageUrl.isNotEmpty
? CircleAvatar(
radius: d / 2,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
maxWidth: 144,
maxHeight: 144,
),
)
: CircleAvatar(
radius: d / 2,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: d * 0.36,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ProfileHeroName(
tag: heroTag,
text: name,
style: nameStyle,
child: Text(
name,
style: nameStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
if (isOfficial) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
),
ValueListenableBuilder<String>(
valueListenable: headerStatus,
builder: (context, status, _) => Text(
status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w400,
),
),
),
],
),
),
],
),
),
),
const SizedBox(width: 8),
GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
liquid: liquid,
backdropKey: backdropKey,
padding: const EdgeInsets.symmetric(horizontal: 2),
child: SizedBox(
height: 56,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ValueListenableBuilder<int>(
valueListenable: scheduledCount,
builder: (_, count, _) => count > 0
? IconButton(
icon: Icon(
Symbols.schedule,
weight: 500,
color: cs.onSurface,
),
onPressed: onOpenScheduled,
)
: const SizedBox.shrink(),
),
if (showCall)
IconButton(
icon: Icon(
Symbols.call,
weight: 500,
color: cs.onSurface,
),
onPressed: onCall,
),
Builder(
builder: (btnContext) => IconButton(
icon: Icon(
Symbols.more_vert,
weight: 500,
color: cs.onSurface,
),
onPressed: () => onMenu(btnContext),
),
),
],
),
),
),
],
),
);
}
Widget _materialRow(BuildContext context) {
final nameStyle = TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
);
return Row(
children: [
_backWithBadge(
cs,
IconButton(
icon: Icon(
embedded ? Symbols.close : Symbols.arrow_back,
weight: 400,
color: cs.onSurface,
),
onPressed: () {
if (embedded) {
onClose?.call();
} else {
Navigator.pop(context);
}
},
),
),
Expanded(
child: InkWell(
onTap: onOpenInfo,
child: Row(
children: [
_withOnlineDot(
cs,
_heroAvatar(
36,
(d) => imageUrl.isNotEmpty
? CircleAvatar(
radius: d / 2,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
maxWidth: 144,
maxHeight: 144,
),
)
: CircleAvatar(
radius: d / 2,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: d / 3,
),
),
),
),
dotSize: 11,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ProfileHeroName(
tag: heroTag,
text: name,
style: nameStyle,
child: Text(
name,
style: nameStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
if (isOfficial) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
),
ValueListenableBuilder<String>(
valueListenable: headerStatus,
builder: (context, status, _) => Text(
status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
],
),
),
],
),
),
),
ValueListenableBuilder<int>(
valueListenable: scheduledCount,
builder: (_, count, _) => count > 0
? IconButton(
icon: Icon(
Symbols.schedule,
weight: 400,
color: cs.onSurface,
),
onPressed: onOpenScheduled,
)
: const SizedBox.shrink(),
),
if (showCall)
IconButton(
icon: Icon(Symbols.call, weight: 400, color: cs.onSurface),
onPressed: onCall,
),
Builder(
builder: (btnContext) => IconButton(
icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface),
onPressed: () => onMenu(btnContext),
),
),
],
);
}
int get _storyOwnerId => chatType == 'DIALOG' ? chatId ^ myId : chatId;
Widget _heroAvatar(
double size,
Widget Function(double diameter) avatarBuilder,
) {
final ownerId = _storyOwnerId;
if (!AppStories.current.value || ownerId <= 0) {
return ProfileHeroAvatar(
tag: heroTag,
size: size,
child: avatarBuilder(size),
);
}
const gap = 3.0;
return ValueListenableBuilder<int>(
valueListenable: storiesModule.storiesChanged,
builder: (context, _, _) {
final preview = storiesModule.previewFor(ownerId);
final hasStory = preview != null && !preview.isEmpty;
final inner = hasStory ? size - gap * 2 : size;
return GestureDetector(
behavior: hasStory
? HitTestBehavior.opaque
: HitTestBehavior.deferToChild,
onTap: hasStory ? () => _openStories(context, preview) : null,
child: StoryAvatarRing(
diameter: inner,
total: preview?.totalCount ?? 0,
read: preview?.readCount ?? 0,
strokeWidth: 2,
ringGap: hasStory ? gap : 0,
haloWidth: 1.2,
child: ProfileHeroAvatar(
tag: heroTag,
size: inner,
child: avatarBuilder(inner),
),
),
);
},
);
}
void _openStories(BuildContext context, StoryPreview preview) {
Haptics.tap();
unawaited(
openStoryViewer(
context,
previews: [preview],
origin: storyOriginOf(context),
ownerOverrides: {
preview.owner.ownerId: StoryOwnerInfo(
name: name,
avatarUrl: imageUrl.isEmpty ? null : imageUrl,
),
},
),
);
}
Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) {
final otherId = chatId ^ myId;
final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0;
return Stack(
clipBehavior: Clip.none,
children: [
avatar,
if (showDot)
Positioned(
right: 0,
bottom: 0,
child: OnlineDot(
userId: otherId,
borderColor: cs.surface,
size: dotSize,
),
),
if (encrypted)
Positioned(
left: -2,
bottom: -2,
child: EncryptionLockBadge(size: dotSize + 4),
),
],
);
}
Widget _backWithBadge(ColorScheme cs, Widget button) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
button,
Positioned(
right: -2,
bottom: 0,
child: IgnorePointer(child: _backUnreadBadge(cs)),
),
],
);
}
Widget _backUnreadBadge(ColorScheme cs) {
return ValueListenableBuilder<int>(
valueListenable: otherUnread,
builder: (context, count, _) {
return AnimatedScale(
scale: count > 0 ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: Container(
constraints: const BoxConstraints(minWidth: 18),
height: 18,
padding: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(9),
border: Border.all(color: cs.surface, width: 1.5),
),
alignment: Alignment.center,
child: _RollingCount(
count: count > 99 ? 99 : count,
style: TextStyle(
color: cs.onPrimary,
fontSize: 10.5,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
);
},
);
}
}
class _RollingCount extends StatefulWidget {
final int count;
final TextStyle style;
const _RollingCount({required this.count, required this.style});
@override
State<_RollingCount> createState() => _RollingCountState();
}
class _RollingCountState extends State<_RollingCount> {
late int _count = widget.count;
bool _increasing = true;
@override
void didUpdateWidget(_RollingCount old) {
super.didUpdateWidget(old);
if (widget.count != _count) {
_increasing = widget.count > _count;
_count = widget.count;
}
}
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, anim) {
final incoming = (child.key as ValueKey<int>).value == _count;
final Offset begin;
if (incoming) {
begin = _increasing ? const Offset(0, -1) : const Offset(0, 1);
} else {
begin = _increasing ? const Offset(0, 1) : const Offset(0, -1);
}
return ClipRect(
child: FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween(begin: begin, end: Offset.zero).animate(anim),
child: child,
),
),
);
},
child: Text(
'${widget.count}',
key: ValueKey<int>(widget.count),
style: widget.style,
),
);
}
}