Files
feeddeck/app/lib/widgets/item/preview/utils/item_actions.dart
Rico Berger 3620fcb5b0 [core] Wrap Actions in SafeArea Widget (#172)
With this commit we are wrapping all action widgets in a `SafeArea`
widget, to improve the alignment of the actions.
2024-06-08 18:30:04 +02:00

402 lines
13 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:feeddeck/models/item.dart';
import 'package:feeddeck/repositories/items_repository.dart';
import 'package:feeddeck/utils/constants.dart';
import 'package:feeddeck/utils/openurl.dart';
/// The [ItemActions] widget provides an actions menu for an item, which can be
/// used to quickly mark an item as read or unread and to add or remove a
/// bookmark for the item.
///
/// On Android and iOS the actions menu is displayed as a [Dismissible], this
/// means a user has to swipe the item to the left / right to add / remove a
/// bookmark for the item or to mark the item as read / unread.
///
/// For all other devices the actions menu is shown when a user presses longer
/// on a item, if the user does a normal click the primary action provided via
/// the [onTap] parameter is executed.
class ItemActions extends StatefulWidget {
const ItemActions({
super.key,
required this.item,
required this.onTap,
required this.children,
});
final FDItem item;
final void Function()? onTap;
final List<Widget> children;
@override
State<ItemActions> createState() => _ItemActionsState();
}
class _ItemActionsState extends State<ItemActions> {
Offset _tapPosition = Offset.zero;
/// [_bookmark] marks the item as bookmarked. If the item is already
/// bookmarked the bookmark will be removed.
Future<void> _bookmark(BuildContext context) async {
try {
await Provider.of<ItemsRepository>(
context,
listen: false,
).updateBookmarkedState(widget.item.id, !widget.item.isBookmarked);
} catch (_) {}
}
/// [_read] marks the item as read / unread by calling the corresponding API
/// endpoint.
Future<void> _read(BuildContext context) async {
try {
await Provider.of<ItemsRepository>(
context,
listen: false,
).updateReadState(widget.item.id, !widget.item.isRead);
} catch (_) {}
}
/// [_openUrl] opens the item url in the default browser of the current
/// device.
Future<void> _openUrl() async {
try {
await openUrl(widget.item.link);
} catch (_) {}
}
/// [_getTapPositionLarge] set the [_tapPosition] which will be used for the
/// actions menu.
void _getTapPositionLarge(TapDownDetails details) {
setState(() {
_tapPosition = details.globalPosition;
});
}
/// [_showActionsMenuLarge] shows a popup menu with all available actions for
/// an item. This means the user can mark an item as read or unread or a user
/// can add or remove a bookmark for an item.
void _showActionsMenuLarge() async {
HapticFeedback.heavyImpact();
final RenderObject? overlay =
Overlay.of(context).context.findRenderObject();
final result = await showMenu(
context: context,
position: RelativeRect.fromRect(
Rect.fromLTWH(
_tapPosition.dx,
_tapPosition.dy,
30,
30,
),
Rect.fromLTWH(
0,
0,
overlay!.paintBounds.size.width,
overlay.paintBounds.size.height,
),
),
items: [
PopupMenuItem(
value: 'read',
child: ListTile(
leading: widget.item.isRead
? const Icon(
Icons.visibility_off,
)
: const Icon(
Icons.visibility,
),
title: widget.item.isRead
? const Text('Mark as Unread')
: const Text('Mark as Read'),
),
),
PopupMenuItem(
value: 'bookmark',
child: ListTile(
leading: widget.item.isBookmarked
? const Icon(
Icons.bookmark,
)
: const Icon(
Icons.bookmark_outline,
),
title: widget.item.isBookmarked
? const Text('Remove Bookmark')
: const Text('Add Bookmark'),
),
),
const PopupMenuItem(
value: 'openlink',
child: ListTile(
leading: Icon(
Icons.launch,
),
title: Text('Open Link'),
),
),
],
);
switch (result) {
case 'read':
if (mounted) {
await _read(context);
}
break;
case 'bookmark':
if (mounted) {
await _bookmark(context);
}
break;
case 'openlink':
if (mounted) {
await _openUrl();
}
break;
}
}
/// [_showActionsMenuSmall] shows a modal bottom sheet with all available
/// actions for an item. This means the user can mark an item as read or
/// unread or a user can add or remove a bookmark for an item. The actions are
/// the same as we show on large screens via [_showActionsMenuLarge], but the
/// modal bottom sheet is optiomized for small screens.
void _showActionsMenuSmall(BuildContext mainContext) async {
HapticFeedback.heavyImpact();
showModalBottomSheet(
context: mainContext,
isScrollControlled: true,
isDismissible: true,
useSafeArea: true,
elevation: 0,
backgroundColor: Colors.transparent,
constraints: const BoxConstraints(
maxWidth: Constants.centeredFormMaxWidth,
),
builder: (BuildContext context) {
return SafeArea(
child: Container(
margin: const EdgeInsets.all(
Constants.spacingMiddle,
),
padding: const EdgeInsets.only(
left: Constants.spacingMiddle,
right: Constants.spacingMiddle,
),
decoration: const BoxDecoration(
color: Constants.background,
borderRadius: BorderRadius.all(
Radius.circular(Constants.spacingMiddle),
),
),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
ListTile(
mouseCursor: SystemMouseCursors.click,
onTap: () async {
Navigator.of(context).pop();
_read(mainContext);
},
leading: widget.item.isRead
? const Icon(Icons.visibility_off)
: const Icon(Icons.visibility),
title: widget.item.isRead
? const Text('Mark as Unread')
: const Text('Mark as Read'),
),
const Divider(
color: Constants.dividerColor,
height: 1,
thickness: 1,
),
ListTile(
mouseCursor: SystemMouseCursors.click,
onTap: () async {
Navigator.of(context).pop();
_bookmark(mainContext);
},
leading: widget.item.isBookmarked
? const Icon(Icons.bookmark)
: const Icon(Icons.bookmark_outline),
title: widget.item.isBookmarked
? const Text('Remove Bookmark')
: const Text('Add Bookmark'),
),
const Divider(
color: Constants.dividerColor,
height: 1,
thickness: 1,
),
ListTile(
mouseCursor: SystemMouseCursors.click,
onTap: () async {
Navigator.of(context).pop();
_openUrl();
},
leading: const Icon(Icons.launch),
title: const Text('Open Link'),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
bool useTabs = MediaQuery.of(context).size.width < Constants.breakpoint;
/// On small screens on Android and iOS we show a [Dismissible] and an
/// actions menu (via `_showActionsMenuSmall`), so that a user can mark an
/// item as read or unread or a user can add or remove a bookmark by
/// swiping the item to the left or right or by a long press.
if (!kIsWeb && useTabs && (Platform.isAndroid || Platform.isIOS)) {
return Dismissible(
key: Key(widget.item.id),
background: Container(
color: Constants.primary,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: Constants.spacingMiddle,
),
child: widget.item.isBookmarked
? const Icon(
Icons.bookmark,
)
: const Icon(
Icons.bookmark_outline,
),
),
),
),
secondaryBackground: Container(
color: Constants.primary,
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(
right: Constants.spacingMiddle,
),
child: widget.item.isRead
? const Icon(
Icons.visibility_off,
)
: const Icon(
Icons.visibility,
),
),
),
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
HapticFeedback.heavyImpact();
await _bookmark(context);
return false;
} else {
HapticFeedback.heavyImpact();
await _read(context);
return false;
}
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
onLongPress: () => _showActionsMenuSmall(context),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Constants.secondary,
border: Border(
bottom: BorderSide(color: Constants.dividerColor),
),
),
padding: const EdgeInsets.all(Constants.spacingSmall),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.children,
),
),
),
),
);
}
/// On other small screens we only show the actions menu via
/// `_showActionsMenuSmall` to offer the same actions. We do not use the
/// [Dismissible] widget here, because it could cause some problems with the
/// scrolling behaviour.
if (useTabs) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
onLongPress: () => _showActionsMenuSmall(context),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Constants.secondary,
border: Border(
bottom: BorderSide(color: Constants.dividerColor),
),
),
padding: const EdgeInsets.all(Constants.spacingSmall),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.children,
),
),
),
);
}
/// On large screens we show an actions menu via `_showActionsMenuLarge`,
/// which is rendered directly at the point where the user pressed on the
/// item.
/// The menu can be opened by a long press or by a secondary tap (right
/// click) on the item.
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
onTapDown: (details) => _getTapPositionLarge(details),
onLongPress: () => _showActionsMenuLarge(),
onSecondaryTapDown:
kIsWeb ? null : (details) => _getTapPositionLarge(details),
onSecondaryTap: kIsWeb ? null : () => _showActionsMenuLarge(),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Constants.secondary,
border: Border(
bottom: BorderSide(color: Constants.dividerColor),
),
),
padding: const EdgeInsets.all(Constants.spacingSmall),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.children,
),
),
),
);
}
}