Files
Qlyra/lib/frontend/screens/stories/story_ring.dart
T

457 lines
14 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/utils/haptics.dart';
import '../../../models/story.dart';
import '../../widgets/komet_avatar.dart';
import 'story_owner_info.dart';
class StoryAvatarRing extends StatelessWidget {
final double diameter;
final int total;
final int read;
final double strokeWidth;
final double ringGap;
final double haloWidth;
final Widget child;
const StoryAvatarRing({
super.key,
required this.diameter,
required this.child,
this.total = 0,
this.read = 0,
this.strokeWidth = 2.8,
this.ringGap = 6,
this.haloWidth = 2,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final outer = diameter + ringGap * 2;
final visible = total > 0;
return SizedBox(
width: outer,
height: outer,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: Size.square(outer),
painter: visible
? SegmentedRingPainter(
total: total,
read: read,
unreadColors: [cs.primary, cs.tertiary, cs.primary],
readColor: cs.outlineVariant,
strokeWidth: strokeWidth,
)
: null,
),
Container(
width: diameter + haloWidth * 2,
height: diameter + haloWidth * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: visible ? cs.surface : Colors.transparent,
),
),
child,
],
),
);
}
}
/// Кольцо-превью истории владельца в шапке списка чатов.
class StoryRing extends StatefulWidget {
final StoryPreview preview;
final StoryOwnerInfo? ownerOverride;
final String? selfLabel;
final void Function(Offset? center) onTap;
final double avatarRadius;
const StoryRing({
super.key,
required this.preview,
required this.onTap,
this.ownerOverride,
this.selfLabel,
this.avatarRadius = 26,
});
@override
State<StoryRing> createState() => _StoryRingState();
}
class _StoryRingState extends State<StoryRing> {
bool _pressed = false;
void _handleTap() {
Haptics.tap();
Offset? center;
final box = context.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) {
center = box.localToGlobal(box.size.center(Offset.zero));
}
widget.onTap(center);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final hasUnread = widget.preview.hasUnread;
final diameter = widget.avatarRadius * 2;
return StoryOwnerBuilder(
owner: widget.preview.owner,
overrideInfo: widget.ownerOverride,
builder: (context, info) {
final name = widget.selfLabel ??
(info?.name.isNotEmpty == true ? info!.name : '…');
return Padding(
padding: const EdgeInsets.only(right: 16),
child: SizedBox(
width: 68,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleTap,
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
child: AnimatedScale(
scale: _pressed ? 0.9 : 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
StoryAvatarRing(
diameter: diameter,
total: widget.preview.totalCount,
read: widget.preview.readCount,
child: KometAvatar(
name: name == '…' ? '?' : name,
size: diameter,
imageUrl: info?.avatarUrl,
),
),
const SizedBox(height: 6),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 11,
fontWeight: hasUnread
? FontWeight.w600
: FontWeight.w500,
),
),
],
),
),
),
),
);
},
);
}
}
/// Прерывистое кольцо: одна дуга на каждую историю; прочитанные приглушены.
class SegmentedRingPainter extends CustomPainter {
final int total;
final int read;
final List<Color> unreadColors;
final Color readColor;
final double strokeWidth;
SegmentedRingPainter({
required this.total,
required this.read,
required this.unreadColors,
required this.readColor,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final n = total < 1 ? 1 : total;
final rect = Rect.fromLTWH(
strokeWidth / 2,
strokeWidth / 2,
size.width - strokeWidth,
size.height - strokeWidth,
);
final segment = (2 * math.pi) / n;
final gap = n == 1 ? 0.0 : math.min(0.16, segment * 0.30);
final sweep = segment - gap;
final unreadPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = n == 1 ? StrokeCap.butt : StrokeCap.round
..shader = SweepGradient(
startAngle: 0,
endAngle: 2 * math.pi,
colors: [...unreadColors, unreadColors.first],
transform: const GradientRotation(-math.pi / 2),
).createShader(rect);
final readPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = n == 1 ? StrokeCap.butt : StrokeCap.round
..color = readColor;
for (var i = 0; i < n; i++) {
final start = -math.pi / 2 + gap / 2 + i * segment;
canvas.drawArc(rect, start, sweep, false, i < read ? readPaint : unreadPaint);
}
}
@override
bool shouldRepaint(SegmentedRingPainter old) =>
old.total != total ||
old.read != read ||
old.readColor != readColor ||
old.strokeWidth != strokeWidth ||
!listEquals(old.unreadColors, unreadColors);
}
/// Ведущая плитка «Ваша история»: показывает своё кольцо (если истории есть)
/// и всегда — бейдж «+» для публикации. Тап по кольцу открывает свои истории,
/// тап по «+» — композер. Если своих историй нет — вся плитка ведёт в композер.
class StorySelfTile extends StatefulWidget {
final StoryPreview? preview;
final StoryOwnerInfo? selfInfo;
final void Function(Offset? center) onOpen;
final VoidCallback onAdd;
final double avatarRadius;
const StorySelfTile({
super.key,
required this.onOpen,
required this.onAdd,
this.preview,
this.selfInfo,
this.avatarRadius = 26,
});
@override
State<StorySelfTile> createState() => _StorySelfTileState();
}
class _StorySelfTileState extends State<StorySelfTile> {
bool _pressed = false;
bool get _hasStories => widget.preview != null;
void _handleTap() {
Haptics.tap();
if (!_hasStories) {
widget.onAdd();
return;
}
Offset? center;
final box = context.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) {
center = box.localToGlobal(box.size.center(Offset.zero));
}
widget.onOpen(center);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final diameter = widget.avatarRadius * 2;
final preview = widget.preview;
return Padding(
padding: const EdgeInsets.only(right: 16),
child: SizedBox(
width: 68,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleTap,
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
child: AnimatedScale(
scale: _pressed ? 0.9 : 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: diameter + 12,
height: diameter + 12,
child: Stack(
alignment: Alignment.center,
children: [
if (preview != null)
CustomPaint(
size: Size.square(diameter + 12),
painter: SegmentedRingPainter(
total: preview.totalCount,
read: preview.readCount,
unreadColors: [cs.primary, cs.tertiary, cs.primary],
readColor: cs.outlineVariant,
strokeWidth: 2.8,
),
)
else
Container(
width: diameter + 6,
height: diameter + 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.outlineVariant,
width: 2,
),
),
),
Container(
width: diameter + 4,
height: diameter + 4,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.surface,
),
),
KometAvatar(
name: widget.selfInfo?.name.isNotEmpty == true
? widget.selfInfo!.name
: '+',
size: diameter,
imageUrl: widget.selfInfo?.avatarUrl,
),
Positioned(
right: 1,
bottom: 1,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Haptics.tap();
widget.onAdd();
},
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.surface,
),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primary,
),
child: Icon(
Icons.add,
size: 14,
color: cs.onPrimary,
),
),
),
),
),
],
),
),
const SizedBox(height: 6),
Text(
'Ваша история',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
);
}
}
/// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке.
class FoldedStoryStack extends StatelessWidget {
static const int maxShown = 3;
static const double avatarSize = 28;
static const double _rim = 1.5;
static const double _gap = 1.5;
static const double step = 14;
static const double outerSize = avatarSize + (_rim + _gap) * 2;
static double widthFor(int count) {
final shown = count > maxShown ? maxShown : (count < 1 ? 1 : count);
return outerSize + step * (shown - 1);
}
final List<StoryPreview> previews;
final double opacity;
const FoldedStoryStack({
super.key,
required this.previews,
this.opacity = 1.0,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final shown = previews.take(maxShown).toList();
return Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: SizedBox(
height: outerSize,
width: widthFor(shown.length),
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < shown.length; i++)
Positioned(
left: i * step,
top: 0,
child: StoryOwnerBuilder(
owner: shown[i].owner,
builder: (context, info) => Container(
padding: const EdgeInsets.all(_gap),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.surface,
border: Border.all(
color: shown[i].hasUnread
? cs.primary
: cs.outlineVariant,
width: _rim,
),
),
child: KometAvatar(
name: info?.name.isNotEmpty == true ? info!.name : '?',
size: avatarSize,
imageUrl: info?.avatarUrl,
),
),
),
),
],
),
),
);
}
}