1e97716636
plugins/window replaces the window_manager fork on every desktop. Changelog: Tray hotkeys, node delays and a delay test
512 lines
14 KiB
Dart
512 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/icons/icons.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:fl_clash/providers/app.dart';
|
|
import 'package:fl_clash/providers/state.dart';
|
|
import 'package:fl_clash/widgets/navigation_dock.dart';
|
|
import 'package:fl_clash/widgets/theme.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
const _actionMinDuration = Duration(seconds: 6);
|
|
const _maxBufferedMessages = 8;
|
|
const _mobileMaxVisibleMessages = 2;
|
|
const _desktopMaxVisibleMessages = 4;
|
|
const _messageEnterDuration = Duration(milliseconds: 500);
|
|
const _messageExitDuration = Duration(milliseconds: 400);
|
|
const _messageCollapseDuration = Duration(milliseconds: 200);
|
|
const _messageEnterOffset = Offset(0.32, 0);
|
|
const _messageMaxWidth = 500.0;
|
|
const _messageMinHeight = 54.0;
|
|
|
|
class StatusManager extends ConsumerStatefulWidget {
|
|
final Widget child;
|
|
|
|
const StatusManager({super.key, required this.child});
|
|
|
|
@override
|
|
ConsumerState<StatusManager> createState() => StatusManagerState();
|
|
}
|
|
|
|
class StatusManagerState extends ConsumerState<StatusManager> {
|
|
final _entriesNotifier = ValueNotifier<List<_MessageEntry>>([]);
|
|
final _bufferMessages = Queue<CommonMessage>();
|
|
final _activeTimers = <String, Timer>{};
|
|
String? _draggingId;
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final timer in _activeTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_activeTimers.clear();
|
|
_bufferMessages.clear();
|
|
_entriesNotifier.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
int get _maxVisibleMessages => ref.read(isMobileViewProvider)
|
|
? _mobileMaxVisibleMessages
|
|
: _desktopMaxVisibleMessages;
|
|
|
|
List<_MessageEntry> get _entries => _entriesNotifier.value;
|
|
|
|
Iterable<_MessageEntry> get _visibleEntries =>
|
|
_entries.where((entry) => entry.visible);
|
|
|
|
void message(
|
|
String text, {
|
|
MessageLevel level = MessageLevel.info,
|
|
MessageActionState? actionState,
|
|
}) {
|
|
if (text.isEmpty) {
|
|
return;
|
|
}
|
|
final commonMessage = CommonMessage(
|
|
id: uuidV4,
|
|
text: text,
|
|
level: level,
|
|
duration: _resolveDuration(level, actionState),
|
|
actionState: actionState,
|
|
);
|
|
commonPrint.log('message: $text');
|
|
if (_mergeMessage(commonMessage)) {
|
|
return;
|
|
}
|
|
_bufferMessages.add(commonMessage);
|
|
_trimBuffer();
|
|
_processQueue();
|
|
}
|
|
|
|
Duration _resolveDuration(
|
|
MessageLevel level,
|
|
MessageActionState? actionState,
|
|
) {
|
|
final duration = level.duration;
|
|
if (actionState == null || duration >= _actionMinDuration) {
|
|
return duration;
|
|
}
|
|
return _actionMinDuration;
|
|
}
|
|
|
|
bool _isSameMessage(CommonMessage a, CommonMessage b) =>
|
|
a.text == b.text && a.level == b.level;
|
|
|
|
bool _mergeMessage(CommonMessage message) {
|
|
final entries = _entries;
|
|
final index = entries.indexWhere(
|
|
(entry) => entry.visible && _isSameMessage(entry.message, message),
|
|
);
|
|
if (index != -1) {
|
|
final merged = message.copyWith(id: entries[index].message.id);
|
|
_entriesNotifier.value = List<_MessageEntry>.from(entries)
|
|
..[index] = entries[index].copyWith(message: merged);
|
|
_startTimer(merged);
|
|
return true;
|
|
}
|
|
return _bufferMessages.any((buffered) => _isSameMessage(buffered, message));
|
|
}
|
|
|
|
void _trimBuffer() {
|
|
while (_bufferMessages.length > _maxBufferedMessages) {
|
|
final dropped =
|
|
_bufferMessages.firstWhereOrNull(
|
|
(message) => message.level != MessageLevel.error,
|
|
) ??
|
|
_bufferMessages.first;
|
|
_bufferMessages.remove(dropped);
|
|
}
|
|
}
|
|
|
|
void _processQueue() {
|
|
if (_draggingId != null) {
|
|
return;
|
|
}
|
|
while (_bufferMessages.isNotEmpty) {
|
|
final visible = _visibleEntries.toList();
|
|
if (visible.length < _maxVisibleMessages) {
|
|
_showMessage(_bufferMessages.removeFirst());
|
|
continue;
|
|
}
|
|
final error = _bufferMessages.firstWhereOrNull(
|
|
(message) => message.level == MessageLevel.error,
|
|
);
|
|
if (error == null) {
|
|
return;
|
|
}
|
|
final preempted = visible.lastWhereOrNull(
|
|
(entry) => entry.message.level != MessageLevel.error,
|
|
);
|
|
if (preempted == null) {
|
|
return;
|
|
}
|
|
_hideMessage(preempted.id);
|
|
_bufferMessages.remove(error);
|
|
_showMessage(error);
|
|
}
|
|
}
|
|
|
|
void _showMessage(CommonMessage message) {
|
|
_entriesNotifier.value = List<_MessageEntry>.from(_entries)
|
|
..insert(0, _MessageEntry(message: message, visible: true));
|
|
_startTimer(message);
|
|
}
|
|
|
|
void _startTimer(CommonMessage message) {
|
|
_activeTimers.remove(message.id)?.cancel();
|
|
if (_draggingId != null) {
|
|
return;
|
|
}
|
|
_activeTimers[message.id] = Timer(message.duration, () {
|
|
_hideMessage(message.id);
|
|
_processQueue();
|
|
});
|
|
}
|
|
|
|
void _hideMessage(String id, {bool swiped = false}) {
|
|
_activeTimers.remove(id)?.cancel();
|
|
final entries = _entries;
|
|
final index = entries.indexWhere((entry) => entry.id == id);
|
|
if (index == -1 || !entries[index].visible) {
|
|
return;
|
|
}
|
|
_entriesNotifier.value = List<_MessageEntry>.from(entries)
|
|
..[index] = entries[index].copyWith(visible: false, swiped: swiped);
|
|
}
|
|
|
|
void _cancelMessage(String id, {bool swiped = false}) {
|
|
if (_draggingId == id) {
|
|
_draggingId = null;
|
|
}
|
|
_bufferMessages.removeWhere((message) => message.id == id);
|
|
_hideMessage(id, swiped: swiped);
|
|
_resumeTimers();
|
|
_processQueue();
|
|
}
|
|
|
|
void _setDragging(String id, bool dragging) {
|
|
if (dragging) {
|
|
if (_draggingId == id) {
|
|
return;
|
|
}
|
|
_draggingId = id;
|
|
for (final timer in _activeTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_activeTimers.clear();
|
|
return;
|
|
}
|
|
if (_draggingId != id) {
|
|
return;
|
|
}
|
|
_draggingId = null;
|
|
_resumeTimers();
|
|
_processQueue();
|
|
}
|
|
|
|
void _resumeTimers() {
|
|
for (final entry in _visibleEntries.toList()) {
|
|
if (_activeTimers.containsKey(entry.id)) {
|
|
continue;
|
|
}
|
|
_startTimer(entry.message);
|
|
}
|
|
}
|
|
|
|
void _removeMessage(String id) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
final entries = _entries;
|
|
if (!entries.any((entry) => entry.id == id)) {
|
|
return;
|
|
}
|
|
_entriesNotifier.value = entries
|
|
.where((entry) => entry.id != id)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
widget.child,
|
|
Consumer(
|
|
builder: (_, ref, child) {
|
|
final top = ref.watch(overlayTopOffsetProvider);
|
|
return Container(
|
|
margin: EdgeInsets.only(
|
|
top: top + MediaQuery.viewPaddingOf(context).top + 8,
|
|
),
|
|
child: child,
|
|
);
|
|
},
|
|
child: Align(
|
|
alignment: Alignment.topRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: ValueListenableBuilder(
|
|
valueListenable: _entriesNotifier,
|
|
builder: (_, entries, _) {
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final entry in entries)
|
|
_MessageTransition(
|
|
key: Key(entry.id),
|
|
visible: entry.visible,
|
|
swiped: entry.swiped,
|
|
onHidden: () => _removeMessage(entry.id),
|
|
child: _MessageCard(
|
|
message: entry.message,
|
|
onDismiss: _cancelMessage,
|
|
onDragging: _setDragging,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageEntry {
|
|
const _MessageEntry({
|
|
required this.message,
|
|
required this.visible,
|
|
this.swiped = false,
|
|
});
|
|
|
|
final CommonMessage message;
|
|
final bool visible;
|
|
final bool swiped;
|
|
|
|
String get id => message.id;
|
|
|
|
_MessageEntry copyWith({
|
|
CommonMessage? message,
|
|
bool? visible,
|
|
bool? swiped,
|
|
}) {
|
|
return _MessageEntry(
|
|
message: message ?? this.message,
|
|
visible: visible ?? this.visible,
|
|
swiped: swiped ?? this.swiped,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageTransition extends StatefulWidget {
|
|
const _MessageTransition({
|
|
super.key,
|
|
required this.visible,
|
|
required this.swiped,
|
|
required this.onHidden,
|
|
required this.child,
|
|
});
|
|
|
|
final bool visible;
|
|
final bool swiped;
|
|
final VoidCallback onHidden;
|
|
final Widget child;
|
|
|
|
@override
|
|
State<_MessageTransition> createState() => _MessageTransitionState();
|
|
}
|
|
|
|
class _MessageTransitionState extends State<_MessageTransition>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final CurvedAnimation _size;
|
|
late final CurvedAnimation _opacity;
|
|
late final CurvedAnimation _slide;
|
|
late final Animation<Offset> _offset;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: _messageEnterDuration,
|
|
reverseDuration: _messageExitDuration,
|
|
vsync: this,
|
|
)..addStatusListener(_handleAnimationStatus);
|
|
_size = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: const Interval(0, 0.8, curve: Easing.emphasizedDecelerate),
|
|
reverseCurve: const Interval(0, 0.65, curve: Easing.emphasizedAccelerate),
|
|
);
|
|
_opacity = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: const Interval(0, 0.4, curve: Curves.easeOut),
|
|
reverseCurve: const Interval(0.5, 1, curve: Curves.easeIn),
|
|
);
|
|
_slide = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: const Interval(0, 0.85, curve: Easing.emphasizedDecelerate),
|
|
reverseCurve: const Interval(0.45, 1, curve: Curves.easeInCubic),
|
|
);
|
|
_offset = _slide.drive(Tween(begin: _messageEnterOffset, end: Offset.zero));
|
|
if (widget.visible) {
|
|
_controller.forward();
|
|
return;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && !widget.visible) {
|
|
widget.onHidden();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _MessageTransition oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.visible == oldWidget.visible) {
|
|
return;
|
|
}
|
|
if (widget.visible) {
|
|
_controller.forward();
|
|
return;
|
|
}
|
|
if (widget.swiped) {
|
|
_size.reverseCurve = Easing.emphasizedAccelerate;
|
|
_controller.reverseDuration = _messageCollapseDuration;
|
|
}
|
|
_controller.reverse();
|
|
}
|
|
|
|
void _handleAnimationStatus(AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed && !widget.visible) {
|
|
widget.onHidden();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeStatusListener(_handleAnimationStatus);
|
|
_size.dispose();
|
|
_opacity.dispose();
|
|
_slide.dispose();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SlideTransition(
|
|
position: _offset,
|
|
child: SizeTransition(
|
|
sizeFactor: _size,
|
|
alignment: AlignmentDirectional.topEnd,
|
|
child: FadeTransition(
|
|
opacity: _opacity,
|
|
child: IgnorePointer(ignoring: !widget.visible, child: widget.child),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageCard extends StatelessWidget {
|
|
const _MessageCard({
|
|
required this.message,
|
|
required this.onDismiss,
|
|
required this.onDragging,
|
|
});
|
|
|
|
final CommonMessage message;
|
|
final void Function(String id, {bool swiped}) onDismiss;
|
|
final void Function(String id, bool dragging) onDragging;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 8),
|
|
child: Dismissible(
|
|
key: ValueKey(message.id),
|
|
resizeDuration: null,
|
|
onUpdate: (details) {
|
|
onDragging(message.id, details.progress > 0);
|
|
},
|
|
onDismissed: (_) {
|
|
onDismiss(message.id, swiped: true);
|
|
},
|
|
child: Card(
|
|
margin: EdgeInsets.zero,
|
|
shape: AppShape.lg,
|
|
elevation: 6,
|
|
color: message.level.containerColor(context),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
minHeight: _messageMinHeight,
|
|
maxWidth: _messageMaxWidth,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: _MessageContent(message: message, onDismiss: onDismiss),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageContent extends StatelessWidget {
|
|
const _MessageContent({required this.message, required this.onDismiss});
|
|
|
|
final CommonMessage message;
|
|
final void Function(String id, {bool swiped}) onDismiss;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final actionState = message.actionState;
|
|
final level = message.level;
|
|
final glyph = level.glyph;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (glyph != null) ...[
|
|
GlyphIcon(glyph, size: 20, color: level.iconColor(context)),
|
|
const SizedBox(width: 12),
|
|
],
|
|
Flexible(
|
|
child: Text(
|
|
message.text,
|
|
maxLines: 3,
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
color: level.contentColor(context),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (actionState != null) ...[
|
|
const SizedBox(width: 16),
|
|
CommonMinFilledButtonTheme(
|
|
child: ElasticButton(
|
|
child: FilledButton.tonal(
|
|
onPressed: () {
|
|
onDismiss(message.id);
|
|
actionState.action();
|
|
},
|
|
child: Text(actionState.actionText),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|