mirror of
https://github.com/feeddeck/feeddeck.git
synced 2026-04-30 11:28:45 -05:00
With this commit we are wrapping all action widgets in a `SafeArea` widget, to improve the alignment of the actions.
402 lines
13 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|