aslan-flutter/lib/chatvibe_ui/widgets/room/seat/room_seat_widget.dart
2026-07-01 18:25:58 +08:00

887 lines
29 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:aslan/app_localizations.dart';
import 'package:aslan/chatvibe_ui/components/at_debounce_widget.dart';
import 'package:aslan/chatvibe_ui/components/dialog/dialog_base.dart';
import 'package:aslan/chatvibe_managers/rtc_manager.dart';
import 'package:aslan/chatvibe_features/room/seat/seat_item.dart';
import 'package:aslan/chatvibe_features/webview/game_bs_webview_page.dart';
import 'package:aslan/chatvibe_ui/widgets/room/lucky_gift_tag_widget.dart';
import 'package:aslan/chatvibe_ui/widgets/headdress/headdress_widget.dart';
import '../../../../chatvibe_data/models/enum/at_game_mode_type.dart';
import '../../../../chatvibe_domain/models/res/login_res.dart';
import '../../../../chatvibe_domain/models/res/mic_res.dart';
class RoomSeatWidget extends StatefulWidget {
const RoomSeatWidget({super.key});
@override
_RoomSeatWidgetState createState() => _RoomSeatWidgetState();
}
class _RoomSeatWidgetState extends State<RoomSeatWidget> {
static const Duration _cpHeartLoopInterval = Duration(milliseconds: 3000);
static const Duration _cpHeartReplyDelay = Duration(milliseconds: 360);
static const int _cpHeartBurstCount = 2;
static const Duration _cpHeartBurstStagger = Duration(milliseconds: 140);
final GlobalKey _stackKey = GlobalKey();
final List<_CpHeartFlightData> _activeFlights = <_CpHeartFlightData>[];
Timer? _cpHeartLoopTimer;
int _nextFlightId = 0;
int _lastLayoutDigest = -1;
List<_CpSeatPair> _cpAdjacentPairs = const <_CpSeatPair>[];
List<_CpSeatPair> _cpRemotePairs = const <_CpSeatPair>[];
Map<int, Offset> _seatCenters = const <int, Offset>{};
Map<int, Size> _seatSizes = const <int, Size>{};
@override
void dispose() {
_cpHeartLoopTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<RtcProvider>(
builder: (context, ref, child) {
final bool isGaming = ref.playingLudoGame != null && !ref.isMinLudoGame;
_scheduleCpHeartRefresh(_buildCpLayoutDigest(ref, isGaming), ref);
if (isGaming) {
return Stack(
key: _stackKey,
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 5.w),
ref.roomWheatMap.isNotEmpty
? Container(
padding: EdgeInsets.symmetric(horizontal: 15.w),
height: 45.w,
child: Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
child: Row(
spacing: 15.w,
mainAxisSize: MainAxisSize.min, // 宽度自适应内容
children: List.generate(
ref.roomWheatMap.length,
(index) =>
SeatItem(index: index, isGameModel: true),
),
),
),
),
)
: Container(),
RepaintBoundary(
child: GameBsWebviewPage(
url: ref.playingLudoGame?.gameInfo?.gameCode ?? "",
height: 860.w,
width: ScreenUtil().screenWidth,
gameMode: ATGameModeType.CHAT_ROOM.name,
gameId: ref.playingLudoGame?.gameInfo?.id ?? "",
),
),
SizedBox(height: 5.w),
!ref.isMinLudoGame
? Row(
children: [
Spacer(),
ATDebounceWidget(
child: Image.asset(
"atu_images/room/at_icon_room_game_min.png",
width: 20.w,
height: 20.w,
),
onTap: () {
SmartDialog.show(
tag: "showConfirmDialog",
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
builder: (_) {
return MsgDialog(
title: ATAppLocalizations.of(context)!.tips,
msg:
ATAppLocalizations.of(
context,
)!.returnToVoiceChat,
btnText:
ATAppLocalizations.of(context)!.confirm,
onEnsure: () {
ref.minOrShowLudoGame(true);
},
);
},
);
},
),
SizedBox(width: 8.w),
ref.isFz()
? ATDebounceWidget(
child: Image.asset(
"atu_images/room/at_icon_room_game_close.png",
width: 20.w,
height: 20.w,
),
onTap: () {
SmartDialog.show(
tag: "showConfirmDialog",
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
builder: (_) {
return MsgDialog(
title:
ATAppLocalizations.of(
context,
)!.tips,
msg:
ATAppLocalizations.of(
context,
)!.exitGameMode,
btnText:
ATAppLocalizations.of(
context,
)!.confirm,
onEnsure: () {
ref.gameLudoDisband();
},
);
},
);
},
)
: Container(),
ref.isFz() ? SizedBox(width: 8.w) : Container(),
],
)
: Container(),
],
),
LuckyGiftTagWidget(),
Positioned.fill(
child: IgnorePointer(child: _buildCpAdjacencyOverlay()),
),
Positioned.fill(
child: IgnorePointer(child: _buildCpHeartOverlay()),
),
],
);
}
return Stack(
key: _stackKey,
children: [
ref.roomWheatMap.length == 5
? _buildSeat5()
: (ref.roomWheatMap.length == 10
? _buildSeat10()
: (ref.roomWheatMap.length == 15
? _buildSeat15()
: (ref.roomWheatMap.length == 20
? _buildSeat20()
: Container(height: 180.w)))),
Positioned.fill(
child: IgnorePointer(child: _buildCpAdjacencyOverlay()),
),
Positioned.fill(
child: IgnorePointer(child: _buildCpHeartOverlay()),
),
],
);
},
);
}
int _buildCpLayoutDigest(RtcProvider rtcProvider, bool isGaming) {
int digest = rtcProvider.roomWheatMap.length;
for (final entry in rtcProvider.roomWheatMap.entries) {
final MicRes mic = entry.value;
final String userId = mic.user?.id ?? '';
digest = Object.hash(digest, entry.key, userId);
for (final cp in mic.user?.cpList ?? const <CPRes>[]) {
digest = Object.hash(
digest,
cp.cpUserId ?? '',
cp.micEffectLevel ?? 0,
cp.hasEffectLevel == true ? 1 : 0,
);
}
}
return Object.hash(digest, isGaming ? 1 : 0);
}
void _scheduleCpHeartRefresh(int digest, RtcProvider rtcProvider) {
if (_lastLayoutDigest == digest) {
return;
}
_lastLayoutDigest = digest;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_refreshCpHeartState(rtcProvider);
});
}
void _refreshCpHeartState(RtcProvider rtcProvider) {
final RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox == null || !stackBox.hasSize) {
return;
}
final Map<int, Offset> nextCenters = <int, Offset>{};
final Map<int, Size> nextSizes = <int, Size>{};
for (final entry in rtcProvider.seatGlobalKeyMap.entries) {
final int index = entry.key.toInt();
final BuildContext? seatContext = entry.value.currentContext;
final RenderBox? seatBox = seatContext?.findRenderObject() as RenderBox?;
if (seatBox == null || !seatBox.hasSize) {
continue;
}
final Offset topLeft = seatBox.localToGlobal(
Offset.zero,
ancestor: stackBox,
);
nextSizes[index] = seatBox.size;
nextCenters[index] =
topLeft + Offset(seatBox.size.width / 2, seatBox.size.height / 2);
}
final List<_CpSeatPair> nextPairs = _collectCpSeatPairs(rtcProvider);
final _CpPairGroups nextGroups = _splitCpPairsByDistance(
nextPairs,
nextCenters,
);
final bool pairChanged =
!_samePairs(_cpAdjacentPairs, nextGroups.adjacent) ||
!_samePairs(_cpRemotePairs, nextGroups.remote);
_seatCenters = nextCenters;
_seatSizes = nextSizes;
_cpAdjacentPairs = nextGroups.adjacent;
_cpRemotePairs = nextGroups.remote;
if (pairChanged) {
setState(() {
_activeFlights.clear();
});
}
_configureCpHeartLoop();
}
List<_CpSeatPair> _collectCpSeatPairs(RtcProvider rtcProvider) {
final List<_CpSeatPair> pairs = <_CpSeatPair>[];
final Set<String> seen = <String>{};
for (final entry in rtcProvider.roomWheatMap.entries) {
final int fromIndex = entry.key.toInt();
final ChatVibeUserProfile? user = entry.value.user;
final String fromUserId = user?.id ?? '';
if (fromUserId.isEmpty) {
continue;
}
for (final CPRes cp in user?.cpList ?? const <CPRes>[]) {
final String toUserId = cp.cpUserId ?? '';
if (toUserId.isEmpty) {
continue;
}
final MapEntry<num, MicRes>? targetEntry = rtcProvider
.roomWheatMap
.entries
.cast<MapEntry<num, MicRes>?>()
.firstWhere(
(candidate) => candidate?.value.user?.id == toUserId,
orElse: () => null,
);
if (targetEntry == null) {
continue;
}
final ChatVibeUserProfile? targetUser = targetEntry.value.user;
final int? micEffectLevel = _resolveMutualCpLevel(user, targetUser);
if (micEffectLevel == null) {
continue;
}
final int toIndex = targetEntry.key.toInt();
final int start = math.min(fromIndex, toIndex);
final int end = math.max(fromIndex, toIndex);
final String pairKey = '$start-$end';
if (!seen.add(pairKey)) {
continue;
}
pairs.add(
_CpSeatPair(
fromIndex: start,
toIndex: end,
micEffectLevel: micEffectLevel,
hasNonMicEffect: _resolveMutualNonMicEffectEnabled(
user,
targetUser,
),
),
);
}
}
return pairs;
}
int? _resolveMutualCpLevel(
ChatVibeUserProfile? userA,
ChatVibeUserProfile? userB,
) {
final String userAId = userA?.id ?? '';
final String userBId = userB?.id ?? '';
if (userAId.isEmpty || userBId.isEmpty) {
return null;
}
final CPRes? aRelation = (userA?.cpList ?? const <CPRes>[])
.cast<CPRes?>()
.firstWhere((cp) => cp?.cpUserId == userBId, orElse: () => null);
final CPRes? bRelation = (userB?.cpList ?? const <CPRes>[])
.cast<CPRes?>()
.firstWhere((cp) => cp?.cpUserId == userAId, orElse: () => null);
if (aRelation == null || bRelation == null) {
return null;
}
final int aLevel = (aRelation.micEffectLevel ?? 1).toInt();
final int bLevel = (bRelation.micEffectLevel ?? 1).toInt();
return math.max(aLevel, bLevel).clamp(1, 3);
}
bool _resolveMutualNonMicEffectEnabled(
ChatVibeUserProfile? userA,
ChatVibeUserProfile? userB,
) {
final String userAId = userA?.id ?? '';
final String userBId = userB?.id ?? '';
if (userAId.isEmpty || userBId.isEmpty) {
return false;
}
final CPRes? aRelation = (userA?.cpList ?? const <CPRes>[])
.cast<CPRes?>()
.firstWhere((cp) => cp?.cpUserId == userBId, orElse: () => null);
final CPRes? bRelation = (userB?.cpList ?? const <CPRes>[])
.cast<CPRes?>()
.firstWhere((cp) => cp?.cpUserId == userAId, orElse: () => null);
return aRelation?.hasEffectLevel == true ||
bRelation?.hasEffectLevel == true;
}
String _cpAdjacencyResourceForLevel(int micEffectLevel) {
switch (micEffectLevel) {
case 1:
return "atu_images/room/at_icon_cp_mic_anm_lv_1.svga";
case 3:
return "atu_images/room/at_icon_cp_mic_anm_lv_3.svga";
case 2:
default:
return "atu_images/room/at_icon_cp_mic_anm_lv_2.svga";
}
}
bool _samePairs(List<_CpSeatPair> left, List<_CpSeatPair> right) {
if (left.length != right.length) {
return false;
}
for (int i = 0; i < left.length; i++) {
if (left[i] != right[i]) {
return false;
}
}
return true;
}
_CpPairGroups _splitCpPairsByDistance(
List<_CpSeatPair> pairs,
Map<int, Offset> seatCenters,
) {
if (pairs.isEmpty || seatCenters.length < 2) {
return const _CpPairGroups(
adjacent: <_CpSeatPair>[],
remote: <_CpSeatPair>[],
);
}
final List<Offset> occupiedCenters = seatCenters.values.toList();
double minSeatDistance = double.infinity;
for (int i = 0; i < occupiedCenters.length; i++) {
for (int j = i + 1; j < occupiedCenters.length; j++) {
final double distance =
(occupiedCenters[i] - occupiedCenters[j]).distance;
if (distance < minSeatDistance) {
minSeatDistance = distance;
}
}
}
if (!minSeatDistance.isFinite) {
return _CpPairGroups(adjacent: pairs, remote: const <_CpSeatPair>[]);
}
final double adjacentThreshold = minSeatDistance * 1.18;
final List<_CpSeatPair> adjacent = <_CpSeatPair>[];
final List<_CpSeatPair> remote = <_CpSeatPair>[];
for (final _CpSeatPair pair in pairs) {
final Offset? from = seatCenters[pair.fromIndex];
final Offset? to = seatCenters[pair.toIndex];
if (from == null || to == null) {
continue;
}
final double distance = (from - to).distance;
if (distance <= adjacentThreshold) {
adjacent.add(pair);
} else if (pair.hasNonMicEffect) {
remote.add(pair);
}
}
return _CpPairGroups(adjacent: adjacent, remote: remote);
}
void _configureCpHeartLoop() {
_cpHeartLoopTimer?.cancel();
if (_cpRemotePairs.isEmpty || _seatCenters.isEmpty) {
if (mounted && _activeFlights.isNotEmpty) {
setState(() {
_activeFlights.clear();
});
}
return;
}
_emitCpHeartFlights();
_cpHeartLoopTimer = Timer.periodic(_cpHeartLoopInterval, (_) {
if (!mounted) {
return;
}
_emitCpHeartFlights();
});
}
void _emitCpHeartFlights() {
for (final _CpSeatPair pair in _cpRemotePairs) {
_emitHeartBurst(pair.fromIndex, pair.toIndex);
Timer(_cpHeartReplyDelay, () {
if (!mounted || !_cpRemotePairs.contains(pair)) {
return;
}
_emitHeartBurst(pair.toIndex, pair.fromIndex);
});
}
}
void _emitHeartBurst(int fromIndex, int toIndex) {
for (int i = 0; i < _cpHeartBurstCount; i++) {
final Duration delay = Duration(
milliseconds: _cpHeartBurstStagger.inMilliseconds * i,
);
if (delay == Duration.zero) {
_addHeartFlight(fromIndex, toIndex, variant: i);
continue;
}
Timer(delay, () {
if (!mounted) {
return;
}
_addHeartFlight(fromIndex, toIndex, variant: i);
});
}
}
void _addHeartFlight(int fromIndex, int toIndex, {int variant = 0}) {
final Offset? from = _seatCenters[fromIndex];
final Offset? to = _seatCenters[toIndex];
if (from == null || to == null) {
return;
}
final double distance = (to - from).distance;
final double direction = from.dx <= to.dx ? 1 : -1;
final double arcHeight = math.max(
20.w,
distance * (0.10 + (variant % 3) * 0.025),
);
final double controlOffsetX =
direction * ((variant.isEven ? 1 : -1) * (8.w + (variant * 3.w)));
final double startYOffset = (variant.isEven ? -1 : 1) * (variant * 1.5.w);
setState(() {
_activeFlights.add(
_CpHeartFlightData(
id: _nextFlightId++,
from: from.translate(0, startYOffset),
to: to,
arcHeight: arcHeight,
controlOffsetX: controlOffsetX,
size: 15.w + ((variant % 3) * 2.w),
duration: Duration(milliseconds: 760 + (variant * 70)),
rotationAmplitude: 0.12 + (variant * 0.025),
),
);
});
}
void _removeHeartFlight(int id) {
if (!mounted) {
return;
}
setState(() {
_activeFlights.removeWhere((flight) => flight.id == id);
});
}
Widget _buildCpHeartOverlay() {
if (_activeFlights.isEmpty) {
return const SizedBox.shrink();
}
return Stack(
children:
_activeFlights
.map(
(flight) => _CpHeartFlightWidget(
key: ValueKey<int>(flight.id),
data: flight,
onCompleted: () => _removeHeartFlight(flight.id),
),
)
.toList(),
);
}
Widget _buildCpAdjacencyOverlay() {
if (_cpAdjacentPairs.isEmpty) {
return const SizedBox.shrink();
}
return Stack(
children:
_cpAdjacentPairs.map((pair) {
final Offset? from = _seatCenters[pair.fromIndex];
final Offset? to = _seatCenters[pair.toIndex];
final Size? fromSize = _seatSizes[pair.fromIndex];
final Size? toSize = _seatSizes[pair.toIndex];
if (from == null ||
to == null ||
fromSize == null ||
toSize == null) {
return const SizedBox.shrink();
}
const double width = 94;
const double height = 62;
final double bottomLine = math.max(
from.dy + fromSize.height / 2,
to.dy + toSize.height / 2,
);
final double left = ((from.dx + to.dx) / 2) - width.w / 2;
final double top = bottomLine - (height.w * 0.58);
return Positioned(
key: ValueKey<String>(
'cp-adj-${pair.fromIndex}-${pair.toIndex}-${pair.micEffectLevel}',
),
left: left,
top: top,
child: SizedBox(
width: width.w,
height: height.w,
child: SVGAHeadwearWidget(
resource: _cpAdjacencyResourceForLevel(pair.micEffectLevel),
width: width.w,
height: height.w,
loops: 0,
clearsAfterStop: false,
),
),
);
}).toList(),
);
}
_buildSeat5() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 0)),
Expanded(flex: 1, child: SeatItem(index: 1)),
Expanded(flex: 1, child: SeatItem(index: 2)),
Expanded(flex: 1, child: SeatItem(index: 3)),
Expanded(flex: 1, child: SeatItem(index: 4)),
],
),
],
),
);
}
_buildSeat10() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 0)),
Expanded(flex: 1, child: SeatItem(index: 1)),
Expanded(flex: 1, child: SeatItem(index: 2)),
Expanded(flex: 1, child: SeatItem(index: 3)),
Expanded(flex: 1, child: SeatItem(index: 4)),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(flex: 1, child: SeatItem(index: 5)),
Expanded(flex: 1, child: SeatItem(index: 6)),
Expanded(flex: 1, child: SeatItem(index: 7)),
Expanded(flex: 1, child: SeatItem(index: 8)),
Expanded(flex: 1, child: SeatItem(index: 9)),
],
),
],
),
);
}
_buildSeat15() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 0)),
Expanded(flex: 1, child: SeatItem(index: 1)),
Expanded(flex: 1, child: SeatItem(index: 2)),
Expanded(flex: 1, child: SeatItem(index: 3)),
Expanded(flex: 1, child: SeatItem(index: 4)),
],
),
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 5)),
Expanded(flex: 1, child: SeatItem(index: 6)),
Expanded(flex: 1, child: SeatItem(index: 7)),
Expanded(flex: 1, child: SeatItem(index: 8)),
Expanded(flex: 1, child: SeatItem(index: 9)),
],
),
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 10)),
Expanded(flex: 1, child: SeatItem(index: 11)),
Expanded(flex: 1, child: SeatItem(index: 12)),
Expanded(flex: 1, child: SeatItem(index: 13)),
Expanded(flex: 1, child: SeatItem(index: 14)),
],
),
],
),
);
}
_buildSeat20() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 0)),
Expanded(flex: 1, child: SeatItem(index: 1)),
Expanded(flex: 1, child: SeatItem(index: 2)),
Expanded(flex: 1, child: SeatItem(index: 3)),
Expanded(flex: 1, child: SeatItem(index: 4)),
],
),
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 5)),
Expanded(flex: 1, child: SeatItem(index: 6)),
Expanded(flex: 1, child: SeatItem(index: 7)),
Expanded(flex: 1, child: SeatItem(index: 8)),
Expanded(flex: 1, child: SeatItem(index: 9)),
],
),
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 10)),
Expanded(flex: 1, child: SeatItem(index: 11)),
Expanded(flex: 1, child: SeatItem(index: 12)),
Expanded(flex: 1, child: SeatItem(index: 13)),
Expanded(flex: 1, child: SeatItem(index: 14)),
],
),
Row(
children: [
Expanded(flex: 1, child: SeatItem(index: 15)),
Expanded(flex: 1, child: SeatItem(index: 16)),
Expanded(flex: 1, child: SeatItem(index: 17)),
Expanded(flex: 1, child: SeatItem(index: 18)),
Expanded(flex: 1, child: SeatItem(index: 19)),
],
),
],
),
);
}
}
class _CpSeatPair {
final int fromIndex;
final int toIndex;
final int micEffectLevel;
final bool hasNonMicEffect;
const _CpSeatPair({
required this.fromIndex,
required this.toIndex,
required this.micEffectLevel,
required this.hasNonMicEffect,
});
@override
bool operator ==(Object other) {
return other is _CpSeatPair &&
other.fromIndex == fromIndex &&
other.toIndex == toIndex &&
other.micEffectLevel == micEffectLevel &&
other.hasNonMicEffect == hasNonMicEffect;
}
@override
int get hashCode =>
Object.hash(fromIndex, toIndex, micEffectLevel, hasNonMicEffect);
}
class _CpPairGroups {
final List<_CpSeatPair> adjacent;
final List<_CpSeatPair> remote;
const _CpPairGroups({required this.adjacent, required this.remote});
}
class _CpHeartFlightData {
final int id;
final Offset from;
final Offset to;
final double arcHeight;
final double controlOffsetX;
final double size;
final Duration duration;
final double rotationAmplitude;
const _CpHeartFlightData({
required this.id,
required this.from,
required this.to,
required this.arcHeight,
required this.controlOffsetX,
required this.size,
required this.duration,
required this.rotationAmplitude,
});
}
class _CpHeartFlightWidget extends StatefulWidget {
final _CpHeartFlightData data;
final VoidCallback onCompleted;
const _CpHeartFlightWidget({
super.key,
required this.data,
required this.onCompleted,
});
@override
State<_CpHeartFlightWidget> createState() => _CpHeartFlightWidgetState();
}
class _CpHeartFlightWidgetState extends State<_CpHeartFlightWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.data.duration,
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onCompleted();
}
});
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Offset _bezierPoint(double t) {
final Offset start = widget.data.from;
final Offset end = widget.data.to;
final Offset mid = Offset(
(start.dx + end.dx) / 2 + widget.data.controlOffsetX,
math.min(start.dy, end.dy) - widget.data.arcHeight,
);
final double oneMinusT = 1 - t;
return Offset(
oneMinusT * oneMinusT * start.dx +
2 * oneMinusT * t * mid.dx +
t * t * end.dx,
oneMinusT * oneMinusT * start.dy +
2 * oneMinusT * t * mid.dy +
t * t * end.dy,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final double t = _animation.value;
final Offset point = _bezierPoint(t);
final double scale =
t < 0.35 ? 0.62 + (t * 1.05) : 1.0 - ((t - 0.35) * 0.18);
final double opacity =
t < 0.12 ? t / 0.12 : (t > 0.72 ? 1 - ((t - 0.72) / 0.28) : 1.0);
final double rotation =
math.sin(t * math.pi) * widget.data.rotationAmplitude;
return Positioned(
left: point.dx - widget.data.size / 2,
top: point.dy - widget.data.size / 2,
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: Transform.rotate(
angle: rotation,
child: Transform.scale(scale: scale, child: child),
),
),
);
},
child: Image.asset(
"atu_images/room/at_icon_cp_heart_ap.png",
width: widget.data.size,
height: widget.data.size,
),
);
}
}