Files
IKAR/lib/frontend/widgets/attachment/photo_hero.dart
T
sevenhill 1ef2b7420e
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Port Komet UI improvements and implement IKAR ghost mode
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
2026-10-07 22:07:05 +03:00

365 lines
9.7 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
typedef PhotoHeroOrigin = Rect? Function();
Rect? photoHeroRect(GlobalKey? key) =>
_globalRect(key?.currentContext?.findRenderObject());
Rect? photoHeroRectOf(BuildContext context) =>
context.mounted ? _globalRect(context.findRenderObject()) : null;
Rect? _globalRect(RenderObject? object) {
if (object is! RenderBox || !object.attached || !object.hasSize) return null;
final size = object.size;
if (size.isEmpty) return null;
return MatrixUtils.transformRect(
object.getTransformTo(null),
Offset.zero & size,
);
}
Rect inscribeRect(Size source, Rect box, {bool cover = false}) =>
_inscribe(source, box, cover: cover);
Rect _inscribe(Size source, Rect box, {required bool cover}) {
if (source.isEmpty || box.isEmpty) return box;
final scaleX = box.width / source.width;
final scaleY = box.height / source.height;
final scale = cover ? math.max(scaleX, scaleY) : math.min(scaleX, scaleY);
return Alignment.center.inscribe(
Size(source.width * scale, source.height * scale),
box,
);
}
class PhotoHeroController {
PhotoHeroController({
required this.origin,
ImageProvider? image,
this.size,
this.radius = BorderRadius.zero,
}) : image = ValueNotifier(image);
final PhotoHeroOrigin origin;
final ValueNotifier<ImageProvider?> image;
final ValueNotifier<bool> flying = ValueNotifier(false);
final Size? size;
final BorderRadius radius;
final GlobalKey areaKey = GlobalKey();
bool enabled = true;
Rect? get areaRect => photoHeroRect(areaKey);
Rect? get originRect => enabled ? origin() : null;
bool get canFly => image.value != null && originRect != null;
void dispose() {
image.dispose();
flying.dispose();
}
}
class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
PhotoHeroRoute({required this.hero, required WidgetBuilder builder})
: super(
opaque: false,
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, animation, secondaryAnimation) =>
PhotoHeroScope(controller: hero, child: builder(context)),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_PhotoHeroTransition(
controller: hero,
animation: animation,
child: child,
),
);
final PhotoHeroController hero;
@override
void dispose() {
hero.dispose();
super.dispose();
}
}
class PhotoHeroScope extends InheritedWidget {
const PhotoHeroScope({
super.key,
required this.controller,
required super.child,
});
final PhotoHeroController controller;
static PhotoHeroController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PhotoHeroScope>()?.controller;
@override
bool updateShouldNotify(PhotoHeroScope oldWidget) =>
controller != oldWidget.controller;
}
class PhotoHeroTarget extends StatelessWidget {
const PhotoHeroTarget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) =>
PhotoHeroAnchor(child: PhotoHeroFade(child: child));
}
class PhotoHeroAnchor extends StatelessWidget {
const PhotoHeroAnchor({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final controller = PhotoHeroScope.maybeOf(context);
if (controller == null) return child;
return KeyedSubtree(key: controller.areaKey, child: child);
}
}
class PhotoHeroFade extends StatelessWidget {
const PhotoHeroFade({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final controller = PhotoHeroScope.maybeOf(context);
if (controller == null) return child;
return ValueListenableBuilder<bool>(
valueListenable: controller.flying,
child: child,
builder: (context, flying, child) =>
flying ? Opacity(opacity: 0, child: child) : child!,
);
}
}
class RawImageProvider extends ImageProvider<RawImageProvider> {
const RawImageProvider(this.image);
final ui.Image image;
@override
Future<RawImageProvider> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<RawImageProvider>(this);
@override
ImageStreamCompleter loadImage(
RawImageProvider key,
ImageDecoderCallback decode,
) => OneFrameImageStreamCompleter(
SynchronousFuture<ImageInfo>(ImageInfo(image: image.clone())),
);
@override
bool operator ==(Object other) =>
other is RawImageProvider && identical(other.image, image);
@override
int get hashCode => identityHashCode(image);
}
class _PhotoHeroTransition extends StatefulWidget {
const _PhotoHeroTransition({
required this.controller,
required this.animation,
required this.child,
});
final PhotoHeroController controller;
final Animation<double> animation;
final Widget child;
@override
State<_PhotoHeroTransition> createState() => _PhotoHeroTransitionState();
}
class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> {
ImageStream? _stream;
ImageStreamListener? _listener;
Size? _resolvedSize;
Rect? _from;
Rect? _area;
ImageProvider? _flightProvider;
Widget? _flightImage;
Size? get _imageSize => widget.controller.size ?? _resolvedSize;
bool get _flying => widget.controller.flying.value;
@override
void initState() {
super.initState();
widget.controller.image.addListener(_resolveImage);
widget.animation.addStatusListener(_onStatusChanged);
if (widget.animation.status != AnimationStatus.completed) {
widget.controller.flying.value = widget.controller.canFly;
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_resolveImage();
}
@override
void dispose() {
widget.animation.removeStatusListener(_onStatusChanged);
widget.controller.image.removeListener(_resolveImage);
_detachStream();
super.dispose();
}
void _onStatusChanged(AnimationStatus status) {
switch (status) {
case AnimationStatus.forward:
case AnimationStatus.reverse:
_startFlight();
case AnimationStatus.completed:
case AnimationStatus.dismissed:
_stopFlight();
}
}
void _startFlight() {
_from = null;
_area = null;
_setFlying(widget.controller.canFly);
}
void _stopFlight() => _setFlying(false);
void _setFlying(bool value) {
if (_flying == value) return;
widget.controller.flying.value = value;
if (mounted) setState(() {});
}
void _detachStream() {
final listener = _listener;
if (listener != null) _stream?.removeListener(listener);
_stream = null;
_listener = null;
}
void _resolveImage() {
if (!mounted) return;
final provider = widget.controller.image.value;
if (provider == null) return;
final stream = provider.resolve(createLocalImageConfiguration(context));
if (stream.key == _stream?.key) return;
_detachStream();
final listener = ImageStreamListener((info, synchronous) {
final size = Size(
info.image.width.toDouble(),
info.image.height.toDouble(),
);
info.dispose();
if (_resolvedSize == size) return;
if (_resolvedSize != null && _flying) return;
if (synchronous) {
_resolvedSize = size;
} else if (mounted) {
setState(() => _resolvedSize = size);
}
});
_listener = listener;
_stream = stream..addListener(listener);
}
Widget _imageWidget(ImageProvider provider) {
if (!identical(_flightProvider, provider)) {
_flightProvider = provider;
_flightImage = Image(
image: provider,
fit: BoxFit.fill,
gaplessPlayback: true,
);
}
return _flightImage!;
}
@override
Widget build(BuildContext context) {
final provider = widget.controller.image.value;
if (!_flying || provider == null) {
return FadeTransition(opacity: widget.animation, child: widget.child);
}
return Stack(
children: [
FadeTransition(opacity: widget.animation, child: widget.child),
Positioned.fill(
child: IgnorePointer(
child: AnimatedBuilder(
animation: widget.animation,
child: _imageWidget(provider),
builder: (context, child) => _layoutFlight(child!),
),
),
),
],
);
}
Widget _layoutFlight(Widget image) {
final from = _from ??= widget.controller.originRect;
if (from == null) return const SizedBox.shrink();
final area = _area ??= widget.controller.areaRect;
final imageSize = _imageSize;
if (area == null || imageSize == null) {
return _position(image, from, from, 1);
}
final t = Curves.fastOutSlowIn.transform(
widget.animation.value.clamp(0.0, 1.0),
);
final target = _inscribe(imageSize, area, cover: false);
return _position(
image,
Rect.lerp(from, target, t)!,
Rect.lerp(_inscribe(imageSize, from, cover: true), target, t)!,
1 - t,
);
}
Widget _position(Widget image, Rect clip, Rect rect, double radiusT) {
final radius = widget.controller.radius * radiusT;
final content = Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: rect.left - clip.left,
top: rect.top - clip.top,
width: rect.width,
height: rect.height,
child: image,
),
],
);
return Stack(
children: [
Positioned.fromRect(
rect: clip,
child: radius == BorderRadius.zero
? ClipRect(child: content)
: ClipRRect(borderRadius: radius, child: content),
),
],
);
}
}