| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/scheduler.dart'; |
| |
| import '../primitives/utils.dart'; |
| import 'common_widgets.dart'; |
| import 'theme.dart'; |
| import 'utils.dart'; |
| |
| double get _notificationHeight => scaleByFontFactor(175.0); |
| final _notificationWidth = _notificationHeight * goldenRatio; |
| |
| /// Interface for pushing notifications in the app. |
| /// |
| /// Use this interface in controllers that need to show notifications. |
| /// |
| /// Using the interface instead of the [NotificationsState] implementation |
| /// will allow you to write unit tests for the controller that consumes it |
| /// instead of widget tests. |
| abstract class NotificationService { |
| /// Pushes a notification [message]. |
| void push(String message); |
| } |
| |
| /// Manager for notifications in the app. |
| /// |
| /// Must be inside of an [Overlay]. |
| class Notifications extends StatelessWidget { |
| const Notifications({Key? key, required this.child}) : super(key: key); |
| |
| final Widget child; |
| |
| /// The default duration for notifications to show. |
| static const Duration defaultDuration = Duration(seconds: 7); |
| |
| @override |
| Widget build(BuildContext context) { |
| return Overlay( |
| initialEntries: [ |
| OverlayEntry( |
| builder: (context) => _NotificationsProvider(child: child), |
| maintainState: true, |
| opaque: true, |
| ), |
| ], |
| ); |
| } |
| |
| static NotificationsState? of(BuildContext context) { |
| final provider = |
| context.dependOnInheritedWidgetOfExactType<_InheritedNotifications>(); |
| return provider?.data; |
| } |
| } |
| |
| class _NotificationsProvider extends StatefulWidget { |
| const _NotificationsProvider({Key? key, required this.child}) |
| : super(key: key); |
| |
| final Widget child; |
| |
| @override |
| NotificationsState createState() => NotificationsState(); |
| } |
| |
| class _InheritedNotifications extends InheritedWidget { |
| const _InheritedNotifications({required this.data, required Widget child}) |
| : super(child: child); |
| |
| final NotificationsState data; |
| |
| @override |
| bool updateShouldNotify(_InheritedNotifications oldWidget) { |
| return oldWidget.data != data; |
| } |
| } |
| |
| class NotificationsState extends State<_NotificationsProvider> |
| implements NotificationService { |
| OverlayEntry? _overlayEntry; |
| |
| final List<_Notification> _notifications = []; |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| |
| if (_overlayEntry == null) { |
| _overlayEntry = OverlayEntry( |
| maintainState: true, |
| builder: _buildOverlay, |
| ); |
| SchedulerBinding.instance.scheduleFrameCallback((_) { |
| Overlay.of(context)!.insert(_overlayEntry!); |
| }); |
| } |
| } |
| |
| @override |
| void dispose() { |
| _overlayEntry!.remove(); |
| super.dispose(); |
| } |
| |
| // TODO(peterdjlee): Support clickable links in notification text. See #2268. |
| /// Pushes a notification [message], and returns whether the notification was |
| /// successfully pushed. |
| @override |
| bool push( |
| String message, { |
| List<Widget> actions = const [], |
| Duration duration = Notifications.defaultDuration, |
| bool allowDuplicates = true, |
| }) { |
| if (!allowDuplicates && |
| _notifications.isNotEmpty && |
| _notifications.where((n) => n.message == message).isNotEmpty) { |
| return false; |
| } |
| setState(() { |
| _notifications.add( |
| _Notification( |
| message: message, |
| actions: actions, |
| remove: _removeNotification, |
| duration: duration, |
| ), |
| ); |
| _overlayEntry?.markNeedsBuild(); |
| }); |
| return true; |
| } |
| |
| /// Dismisses all notifications with a matching message. |
| void dismiss(String message) { |
| bool didDismiss = false; |
| // Make a copy so we do not remove a notification from [_notifications] |
| // while iterating over it. |
| final notifications = List.from(_notifications); |
| for (final notification in notifications) { |
| if (notification.tooltip == message) { |
| _notifications.remove(notification); |
| didDismiss = true; |
| } |
| } |
| if (didDismiss) { |
| setState(() { |
| _overlayEntry?.markNeedsBuild(); |
| }); |
| } |
| } |
| |
| void _removeNotification(_Notification notification) { |
| setState(() { |
| final didRemove = _notifications.remove(notification); |
| if (didRemove) { |
| _overlayEntry?.markNeedsBuild(); |
| } |
| }); |
| } |
| |
| Widget _buildOverlay(BuildContext context) { |
| return Align( |
| alignment: Alignment.bottomRight, |
| child: Padding( |
| // Position the notifications in the lower right of the app window, and |
| // high enough up that we don't obscure the status line. |
| padding: EdgeInsets.only( |
| right: defaultSpacing, |
| bottom: statusLineHeight + defaultSpacing, |
| ), |
| child: SizedBox( |
| width: _notificationWidth, |
| child: SingleChildScrollView( |
| reverse: true, |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| children: _notifications, |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return _InheritedNotifications(data: this, child: widget.child); |
| } |
| } |
| |
| class _Notification extends StatefulWidget { |
| const _Notification({ |
| Key? key, |
| required this.message, |
| this.actions = const [], |
| this.duration = Notifications.defaultDuration, |
| required this.remove, |
| }) : super(key: key); |
| |
| final Duration duration; |
| final String message; |
| final List<Widget> actions; |
| final void Function(_Notification) remove; |
| |
| @override |
| _NotificationState createState() => _NotificationState(); |
| } |
| |
| class _NotificationState extends State<_Notification> |
| with SingleTickerProviderStateMixin { |
| late AnimationController controller; |
| late CurvedAnimation curve; |
| late Timer _dismissTimer; |
| |
| @override |
| void initState() { |
| super.initState(); |
| controller = AnimationController( |
| duration: const Duration(milliseconds: 400), |
| vsync: this, |
| ); |
| curve = CurvedAnimation( |
| parent: controller, |
| curve: Curves.easeInOutCirc, |
| ); |
| // Set up a timer that reverses the entrance animation, and tells the widget |
| // to remove itself when the exit animation is completed. |
| // We can do this because the NotificationsState is directly controlling |
| // the life cycle of each _Notification widget presented in the overlay. |
| _dismissTimer = Timer(widget.duration, () { |
| controller.addStatusListener((status) { |
| if (status == AnimationStatus.dismissed) { |
| widget.remove(widget); |
| } |
| }); |
| controller.reverse(); |
| }); |
| controller.forward(); |
| } |
| |
| @override |
| void dispose() { |
| controller.dispose(); |
| _dismissTimer.cancel(); |
| super.dispose(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final theme = Theme.of(context); |
| return AnimatedBuilder( |
| animation: controller, |
| builder: (context, child) { |
| return Opacity( |
| opacity: curve.value, |
| child: child, |
| ); |
| }, |
| child: Padding( |
| padding: const EdgeInsets.all(denseSpacing), |
| child: Card( |
| color: theme.snackBarTheme.backgroundColor, |
| child: DefaultTextStyle( |
| style: theme.snackBarTheme.contentTextStyle ?? |
| theme.primaryTextTheme.subtitle1!, |
| child: Padding( |
| padding: const EdgeInsets.all(denseSpacing), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| _buildMessage(), |
| const SizedBox(height: defaultSpacing), |
| _buildActions(), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| Widget _buildMessage() { |
| return Text( |
| widget.message, |
| style: Theme.of(context).textTheme.bodyText1, |
| overflow: TextOverflow.visible, |
| maxLines: 6, |
| ); |
| } |
| |
| Widget _buildActions() { |
| if (widget.actions.isEmpty) return const SizedBox(); |
| return Row( |
| mainAxisAlignment: MainAxisAlignment.end, |
| children: widget.actions.joinWith(const SizedBox(width: denseSpacing)), |
| ); |
| } |
| } |
| |
| class NotificationAction extends StatelessWidget { |
| const NotificationAction(this.label, this.onAction, {this.isPrimary = false}); |
| |
| final String label; |
| |
| final VoidCallback onAction; |
| |
| final bool isPrimary; |
| |
| @override |
| Widget build(BuildContext context) { |
| final labelText = Text(label); |
| return isPrimary |
| ? ElevatedButton( |
| onPressed: onAction, |
| child: labelText, |
| ) |
| : OutlinedButton( |
| onPressed: onAction, |
| child: labelText, |
| ); |
| } |
| } |