refactor: call_ui/voice_channel.dart (1123 Zeilen) in 5 thematische part-Dateien unter view/ geteilt (M2, zeichenidentisch verifiziert)
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
class _ParticipantGrid extends StatelessWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final PyramidTheme pt;
|
||||
final Function(_ParticipantData) onSelect;
|
||||
|
||||
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2);
|
||||
const spacing = 12.0;
|
||||
final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: participants.map((p) => SizedBox(
|
||||
width: tileWidth,
|
||||
height: tileWidth / p.aspectRatio,
|
||||
child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)),
|
||||
)).toList(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroLayout extends StatelessWidget {
|
||||
final _ParticipantData hero;
|
||||
final List<_ParticipantData> others;
|
||||
final bool stripCollapsed;
|
||||
final VoidCallback onToggleStrip;
|
||||
final Function(bool) onSetStripCollapsed;
|
||||
final Function(_ParticipantData) onSelectOther;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _HeroLayout({
|
||||
required this.hero,
|
||||
required this.others,
|
||||
required this.stripCollapsed,
|
||||
required this.onToggleStrip,
|
||||
required this.onSetStripCollapsed,
|
||||
required this.onSelectOther,
|
||||
this.onQualityMenu,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600;
|
||||
|
||||
final heroView = _HeroSection(
|
||||
data: hero,
|
||||
onQualityMenu: onQualityMenu,
|
||||
pt: pt,
|
||||
);
|
||||
|
||||
final strip = _ParticipantStrip(
|
||||
participants: others,
|
||||
isVertical: isLandscape,
|
||||
onSelect: onSelectOther,
|
||||
pt: pt,
|
||||
);
|
||||
|
||||
if (isLandscape) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: GestureDetector(
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
|
||||
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
|
||||
},
|
||||
child: heroView,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!stripCollapsed)
|
||||
SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)),
|
||||
_CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
|
||||
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
|
||||
},
|
||||
child: heroView,
|
||||
),
|
||||
),
|
||||
),
|
||||
_CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
|
||||
if (!stripCollapsed)
|
||||
SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)),
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroSection extends StatefulWidget {
|
||||
final _ParticipantData data;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _HeroSection({required this.data, this.onQualityMenu, required this.pt});
|
||||
|
||||
@override
|
||||
State<_HeroSection> createState() => _HeroSectionState();
|
||||
}
|
||||
|
||||
class _HeroSectionState extends State<_HeroSection> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _hovered = !_hovered),
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rLg),
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: widget.data.videoWidget != null
|
||||
? _MirrorWrapper(
|
||||
isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant),
|
||||
isScreenShare: widget.data.isScreenShare,
|
||||
child: widget.data.videoWidget!,
|
||||
)
|
||||
: _BigAvatar(data: widget.data, pt: pt),
|
||||
),
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
top: _hovered ? 0 : -60, left: 0, right: 0,
|
||||
child: _HeroTopBar(data: widget.data, pt: pt),
|
||||
),
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
bottom: _hovered ? 0 : -60, left: 0, right: 0,
|
||||
child: _HeroBottomBar(
|
||||
data: widget.data,
|
||||
onQualityMenu: widget.onQualityMenu,
|
||||
pt: pt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroTopBar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final PyramidTheme pt;
|
||||
const _HeroTopBar({required this.data, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (data.isScreenShare) _LivePill(pt: pt),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
data.isScreenShare ? "${data.name} präsentiert" : data.name,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroBottomBar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if (onQualityMenu != null)
|
||||
_HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)),
|
||||
const SizedBox(width: 8),
|
||||
_HeroActionBtn(
|
||||
icon: Icons.fullscreen_rounded,
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => _FullscreenVideoPage(data: data),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroActionBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
final Function(TapDownDetails)? onTapDown;
|
||||
const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTapDown: onTapDown,
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)),
|
||||
child: Icon(icon, color: Colors.white, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LivePill extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _LivePill({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 4),
|
||||
const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantStrip extends StatelessWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final bool isVertical;
|
||||
final Function(_ParticipantData) onSelect;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
scrollDirection: isVertical ? Axis.vertical : Axis.horizontal,
|
||||
itemCount: participants.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12),
|
||||
itemBuilder: (context, i) => SizedBox(
|
||||
width: isVertical ? double.infinity : 101 * participants[i].aspectRatio,
|
||||
height: 101,
|
||||
child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantTile extends StatefulWidget {
|
||||
final _ParticipantData data;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ParticipantTile({required this.data, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
State<_ParticipantTile> createState() => _ParticipantTileState();
|
||||
}
|
||||
|
||||
class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _speakingCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_speakingCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final data = widget.data;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedBuilder(
|
||||
animation: _speakingCtrl,
|
||||
builder: (context, _) {
|
||||
final isSpeaking = data.isSpeaking;
|
||||
final pulse = isSpeaking ? _speakingCtrl.value : 0.0;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(
|
||||
color: isSpeaking
|
||||
? pt.accent.withAlpha((150 + pulse * 105).toInt())
|
||||
: pt.border,
|
||||
width: isSpeaking ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSpeaking
|
||||
? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)]
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rBase - 1),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: data.videoWidget != null
|
||||
? _MirrorWrapper(
|
||||
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
|
||||
isScreenShare: data.isScreenShare,
|
||||
child: data.videoWidget!,
|
||||
)
|
||||
: _SmallAvatar(data: data, pt: pt),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 6, bottom: 6, right: 6,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)],
|
||||
Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollapseHandle extends StatelessWidget {
|
||||
final bool isVertical;
|
||||
final bool collapsed;
|
||||
final VoidCallback onToggle;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
child: Container(
|
||||
width: isVertical ? 24 : double.infinity,
|
||||
height: isVertical ? double.infinity : 24,
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isVertical
|
||||
? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded)
|
||||
: (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded),
|
||||
color: pt.fgDim, size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user