887 lines
29 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|