31e4001fb0
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf 33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze. Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt; ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen" abgehakt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
137 lines
3.6 KiB
Dart
137 lines
3.6 KiB
Dart
part of '../chat_view.dart';
|
|
|
|
class _TypingIndicator extends StatefulWidget {
|
|
final Room room;
|
|
final PyramidTheme pt;
|
|
const _TypingIndicator({required this.room, required this.pt});
|
|
|
|
@override
|
|
State<_TypingIndicator> createState() => _TypingIndicatorState();
|
|
}
|
|
|
|
class _TypingIndicatorState extends State<_TypingIndicator> {
|
|
StreamSubscription? _sub;
|
|
List<User> _typing = [];
|
|
|
|
StreamSubscription _listenTyping(Room room) {
|
|
// client.onSync fires after every sync — cheap to filter for typing events.
|
|
return room.client.onSync.stream.listen((_) {
|
|
if (!mounted) return;
|
|
final typing = room.typingUsers
|
|
.where((u) => u.id != room.client.userID)
|
|
.toList();
|
|
if (typing.length != _typing.length ||
|
|
!typing.every((u) => _typing.any((t) => t.id == u.id))) {
|
|
setState(() => _typing = typing);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sub = _listenTyping(widget.room);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_TypingIndicator oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.room.id != widget.room.id) {
|
|
_sub?.cancel();
|
|
setState(() => _typing = []);
|
|
_sub = _listenTyping(widget.room);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_typing.isEmpty) return const SizedBox.shrink();
|
|
final pt = widget.pt;
|
|
final names = _typing.map((u) => u.calcDisplayname()).toList();
|
|
final label = names.length == 1
|
|
? '${names[0]} tippt…'
|
|
: names.length == 2
|
|
? '${names[0]} und ${names[1]} tippen…'
|
|
: '${names[0]} und ${names.length - 1} weitere tippen…';
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 2, 20, 0),
|
|
child: Row(
|
|
children: [
|
|
_TypingDots(color: pt.fgDim),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TypingDots extends StatefulWidget {
|
|
final Color color;
|
|
const _TypingDots({required this.color});
|
|
|
|
@override
|
|
State<_TypingDots> createState() => _TypingDotsState();
|
|
}
|
|
|
|
class _TypingDotsState extends State<_TypingDots>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _ctrl;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 900),
|
|
)..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _ctrl,
|
|
builder: (context, child) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: List.generate(3, (i) {
|
|
// Each dot peaks at a different phase.
|
|
final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
|
|
final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 1),
|
|
child: Opacity(
|
|
opacity: opacity,
|
|
child: Container(
|
|
width: 4,
|
|
height: 4,
|
|
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|