Benjamin Wiechel 14 hours ago committed by GitHub
commit 647f1ad428
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,56 +1,25 @@
import 'package:flutter/material.dart';
// showDismissibleSnackBar shows a [SnackBar] with tap to dismiss
// all other properties are default and optional
import '../widgets/top_toast.dart';
/// Shows a transient notification that dismisses on tap, on an upward swipe,
/// or after [duration].
///
/// Kept under its original name so existing call sites are unchanged; the
/// notification itself moved from a bottom [SnackBar] to a top-anchored
/// overlay (#638), because the bottom placement covered the chat composer.
void showDismissibleSnackBar(
BuildContext context, {
Key? key,
required Widget content,
Color? backgroundColor,
double? elevation,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? padding,
double? width,
ShapeBorder? shape,
HitTestBehavior? hitTestBehavior,
SnackBarBehavior? behavior,
SnackBarAction? action,
double? actionOverflowThreshold,
bool? showCloseIcon,
Color? closeIconColor,
Duration? duration,
bool? persist,
Animation<double>? animation,
void Function()? onVisible,
DismissDirection? dismissDirection,
Clip? clipBehavior,
}) {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
key: key,
content: GestureDetector(
onTap: () => messenger.hideCurrentSnackBar(),
child: content,
),
backgroundColor: backgroundColor,
elevation: elevation,
margin: margin,
padding: padding,
width: width,
shape: shape,
hitTestBehavior: hitTestBehavior,
behavior: behavior,
action: action,
actionOverflowThreshold: actionOverflowThreshold,
showCloseIcon: showCloseIcon,
closeIconColor: closeIconColor,
duration: duration ?? const Duration(seconds: 4),
persist: persist,
animation: animation,
onVisible: onVisible,
dismissDirection: dismissDirection ?? DismissDirection.down,
clipBehavior: clipBehavior ?? Clip.hardEdge,
),
TopToast.show(
context,
content: content,
backgroundColor: backgroundColor,
duration: duration ?? TopToast.defaultDuration,
persist: persist ?? false,
);
}

@ -644,7 +644,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Future<void> _blockChannelSender(ChannelMessage message) async {
final connector = context.read<MeshCoreConnector>();
final blockService = context.read<BlockService>();
final messenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
final name = message.senderName;
final keys = connector.resolveContactKeysByName(name);
@ -653,12 +652,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await blockService.block(key);
}
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l10n.block_blocked(name))));
showDismissibleSnackBar(context, content: Text(l10n.block_blocked(name)));
} else {
await blockService.blockName(name);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.block_blockedNameOnly(name))),
showDismissibleSnackBar(
context,
content: Text(l10n.block_blockedNameOnly(name)),
);
}
}

@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import '../connector/caplog_reassembler.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/snack_bar_builder.dart';
import '../utils/log_export.dart';
/// Serial-capture diagnostics screen (#430).
@ -255,10 +256,9 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
final result = await c.downloadCaplog();
if (!mounted) return;
if (result.bytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Buffer is empty, nothing to download.'),
),
showDismissibleSnackBar(
context,
content: const Text('Buffer is empty, nothing to download.'),
);
return;
}

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../helpers/snack_bar_builder.dart';
import '../../models/observer_config.dart';
import '../../services/observer_config_service.dart';
@ -133,11 +134,10 @@ class _BrokerEditorScreenState extends State<BrokerEditorScreen> {
}
void _snack(String msg, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
),
showDismissibleSnackBar(
context,
content: Text(msg),
backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
);
}

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../helpers/config_profile_diff.dart';
import '../../helpers/config_profile_writes.dart';
import '../../helpers/snack_bar_builder.dart';
import '../../models/config_profile.dart';
import '../../services/observer_apply_service.dart';
import '../../services/observer_config_service.dart';
@ -79,13 +80,12 @@ class _ConfigProfilePreviewScreenState
? '$what applied.'
: '$what: ${result.failures.length} of ${result.items.length} failed '
'(${result.failures.first.error ?? 'error'}).';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: result.allOk
? null
: Theme.of(context).colorScheme.error,
),
showDismissibleSnackBar(
context,
content: Text(msg),
backgroundColor: result.allOk
? null
: Theme.of(context).colorScheme.error,
);
await _load(); // re-diff against true state (partial-save recovery)
} finally {

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../connector/meshcore_connector.dart';
import '../../models/observer_config.dart';
import '../../helpers/snack_bar_builder.dart';
import '../../services/observer_config_service.dart';
import 'config_profile_import_screen.dart';
import 'mqtt_brokers_screen.dart';
@ -163,18 +164,16 @@ class _ObserverSettingsViewState extends State<ObserverSettingsView> {
_seedFromConfig(svc.config);
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
invalidInterval
? 'Status interval must be 10–3600 seconds'
: ok
? 'Observer settings saved'
: (svc.lastError ??
'Some changes failed. Re-read from the device'),
),
backgroundColor: ok ? null : Theme.of(context).colorScheme.error,
showDismissibleSnackBar(
context,
content: Text(
invalidInterval
? 'Status interval must be 10–3600 seconds'
: ok
? 'Observer settings saved'
: (svc.lastError ?? 'Some changes failed. Re-read from the device'),
),
backgroundColor: ok ? null : Theme.of(context).colorScheme.error,
);
}

@ -855,8 +855,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// fall through to the failure message
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.settings_aboutLinkFailed)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_aboutLinkFailed),
);
}
}
@ -1064,10 +1065,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () async {
await Clipboard.setData(ClipboardData(text: copyValue));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.settings_publicKeyCopied),
),
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_publicKeyCopied),
);
},
),

@ -0,0 +1,222 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../utils/app_logger.dart';
/// A transient, tap-to-dismiss notification anchored to the TOP of the screen.
///
/// The app previously used bottom-anchored [SnackBar]s, which sat on top of the
/// chat composer (#638). A [SnackBar] is positioned by its [Scaffold] and can
/// only be pushed upward with a near-full-height bottom margin, which delays
/// its reveal animation and turns the whole screen into a hit-test target, so
/// this renders through the [Overlay] instead.
class TopToast {
TopToast._();
static const Duration _animationDuration = Duration(milliseconds: 220);
static const Duration defaultDuration = Duration(seconds: 4);
static const double _maxWidth = 560;
/// Removes the toast currently on screen. Non-null only while one is visible.
static VoidCallback? _dismissCurrent;
/// Shows [content] at the top of the screen.
///
/// When [persist] is true the toast stays until the user dismisses it, for
/// messages that must be read rather than glimpsed (SAFELANE 6).
static void show(
BuildContext context, {
required Widget content,
Color? backgroundColor,
Duration duration = defaultDuration,
bool persist = false,
}) {
final overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) {
// SAFELANE 6: a message we cannot display must not disappear silently.
appLogger.warn(
'No Overlay in this context; notification was not shown',
tag: 'TopToast',
);
return;
}
// A newer message replaces the visible one outright: two entries would
// otherwise stack on top of each other at the same position.
_dismissCurrent?.call();
late final OverlayEntry entry;
var removed = false;
void remove() {
if (removed) return;
removed = true;
entry.remove();
if (_dismissCurrent == remove) {
_dismissCurrent = null;
}
}
entry = OverlayEntry(
builder: (_) => _TopToastHost(
backgroundColor: backgroundColor,
duration: duration,
persist: persist,
animationDuration: _animationDuration,
maxWidth: _maxWidth,
onDismissed: remove,
child: content,
),
);
_dismissCurrent = remove;
overlay.insert(entry);
}
}
class _TopToastHost extends StatefulWidget {
const _TopToastHost({
required this.child,
required this.duration,
required this.persist,
required this.animationDuration,
required this.maxWidth,
required this.onDismissed,
this.backgroundColor,
});
final Widget child;
final Duration duration;
final bool persist;
final Duration animationDuration;
final double maxWidth;
final VoidCallback onDismissed;
final Color? backgroundColor;
@override
State<_TopToastHost> createState() => _TopToastHostState();
}
class _TopToastHostState extends State<_TopToastHost>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: widget.animationDuration,
);
Timer? _timer;
bool _dismissing = false;
@override
void initState() {
super.initState();
_controller.forward();
if (!widget.persist) {
_timer = Timer(widget.duration, _dismiss);
}
}
Future<void> _dismiss() async {
if (_dismissing || !mounted) return;
_dismissing = true;
_timer?.cancel();
try {
await _controller.reverse();
} on TickerCanceled {
// Replaced by a newer toast mid-animation; that path already removed us.
return;
}
if (!mounted) return;
widget.onDismissed();
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final background =
widget.backgroundColor ??
theme.snackBarTheme.backgroundColor ??
theme.colorScheme.inverseSurface;
final textStyle =
theme.snackBarTheme.contentTextStyle ??
theme.textTheme.titleMedium!.copyWith(
color: theme.colorScheme.onInverseSurface,
);
return Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.maxWidth),
child: SlideTransition(
position:
Tween<Offset>(
begin: const Offset(0, -0.4),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
),
child: FadeTransition(
opacity: _controller,
// Parity with SnackBar, which wraps its content the same way
// (snack_bar.dart): without liveRegion a screen reader never
// announces the message at all.
child: Semantics(
container: true,
liveRegion: true,
onDismiss: _dismiss,
child: Material(
color: background,
elevation: 6,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
// One recognizer for both gestures: a nested InkWell would
// also splash on the tap-up that ends a dismissing swipe.
child: GestureDetector(
onTap: _dismiss,
onVerticalDragEnd: (details) {
if ((details.primaryVelocity ?? 0) < 0) {
_dismiss();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: DefaultTextStyle(
style: textStyle,
child: IconTheme(
data: IconThemeData(color: textStyle.color),
child: widget.child,
),
),
),
),
),
),
),
),
),
),
),
),
);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.