feat: improve ui for larger devices

This commit is contained in:
2026-05-15 22:14:43 +03:00
parent 291d8c3bb5
commit eafc267e92
7 changed files with 535 additions and 426 deletions

View File

@@ -8,6 +8,7 @@ import 'package:pantry/services/checklist_service.dart';
import 'package:pantry/services/prefs_service.dart';
import 'package:pantry/utils/category_icons.dart';
import 'package:pantry/utils/rrule.dart';
import 'package:pantry/widgets/context_menu_region.dart';
import 'package:pantry/widgets/image_preview.dart';
import 'package:provider/provider.dart';
@@ -45,68 +46,77 @@ class ChecklistItemTile extends StatelessWidget {
type: MaterialType.transparency,
child: Opacity(
opacity: dimmed ? 0.5 : 1.0,
child: InkWell(
onTap: tapRowToToggle ? () => onToggle(item) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
children: [
Checkbox(value: item.done, onChanged: (_) => onToggle(item)),
if (item.imageFileId != null) ...[
GestureDetector(
onTap: () => _showImagePreview(context),
child: Hero(
tag: 'item-image-${item.id}',
child: _ItemImage(
houseId: houseId,
fileId: item.imageFileId!,
owner: item.imageUploadedBy ?? '',
child: ContextMenuRegion<String>(
itemBuilder: _menuItems,
onSelected: (value) => _onMenuSelected(value),
child: InkWell(
onTap: tapRowToToggle ? () => onToggle(item) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
children: [
Checkbox(value: item.done, onChanged: (_) => onToggle(item)),
if (item.imageFileId != null) ...[
GestureDetector(
onTap: () => _showImagePreview(context),
child: Hero(
tag: 'item-image-${item.id}',
child: _ItemImage(
houseId: houseId,
fileId: item.imageFileId!,
owner: item.imageUploadedBy ?? '',
),
),
),
),
const SizedBox(width: 8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.bodyLarge?.copyWith(
decoration: dimmed
? TextDecoration.lineThrough
: null,
),
),
if (_hasBadges)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _buildBadges(context),
const SizedBox(width: 8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.bodyLarge?.copyWith(
decoration: dimmed
? TextDecoration.lineThrough
: null,
),
),
],
if (_hasBadges)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _buildBadges(context),
),
),
],
),
),
),
IconButton(
icon: Icon(
Icons.visibility_outlined,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
IconButton(
icon: Icon(
Icons.visibility_outlined,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => onView(item),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => onView(item),
),
_MoreMenuButton(
item: item,
onEdit: onEdit,
onMove: onMove,
onDelete: onDelete,
),
],
PopupMenuButton<String>(
icon: Icon(
Icons.more_horiz,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
itemBuilder: (_) => _menuItems(),
onSelected: _onMenuSelected,
),
],
),
),
),
),
@@ -114,6 +124,50 @@ class ChecklistItemTile extends StatelessWidget {
);
}
List<PopupMenuEntry<String>> _menuItems() => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.checklists.editItem),
],
),
),
PopupMenuItem(
value: 'move',
child: Row(
children: [
const Icon(Icons.drive_file_move_outlined, size: 18),
const SizedBox(width: 8),
Text(m.checklists.moveItem),
],
),
),
PopupMenuItem(
value: 'remove',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.checklists.removeItem),
],
),
),
];
void _onMenuSelected(String value) {
switch (value) {
case 'edit':
onEdit(item);
case 'move':
onMove(item);
case 'remove':
onDelete(item);
}
}
void _showImagePreview(BuildContext context) {
final uri = ChecklistService.instance.itemImagePreviewUri(
houseId,
@@ -260,72 +314,3 @@ class _Badge extends StatelessWidget {
);
}
}
class _MoreMenuButton extends StatelessWidget {
final ListItem item;
final ValueChanged<ListItem> onEdit;
final ValueChanged<ListItem> onMove;
final ValueChanged<ListItem> onDelete;
const _MoreMenuButton({
required this.item,
required this.onEdit,
required this.onMove,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: Icon(
Icons.more_horiz,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.checklists.editItem),
],
),
),
PopupMenuItem(
value: 'move',
child: Row(
children: [
const Icon(Icons.drive_file_move_outlined, size: 18),
const SizedBox(width: 8),
Text(m.checklists.moveItem),
],
),
),
PopupMenuItem(
value: 'remove',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.checklists.removeItem),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'edit':
onEdit(item);
case 'move':
onMove(item);
case 'remove':
onDelete(item);
}
},
);
}
}

View File

@@ -1,3 +1,6 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -68,6 +71,8 @@ class _HomeViewBodyState extends State<_HomeViewBody>
final _pageController = PageController();
final _notificationsController = NotificationsController();
static bool get _isMacOS => !kIsWeb && Platform.isMacOS;
@override
void initState() {
super.initState();
@@ -119,11 +124,15 @@ class _HomeViewBodyState extends State<_HomeViewBody>
void _goToTab(int index) {
if (index == _tabIndex) return;
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
);
if (_pageController.hasClients) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
);
} else {
setState(() => _tabIndex = index);
}
}
void _consumePendingDeepLink() {
@@ -161,65 +170,118 @@ class _HomeViewBodyState extends State<_HomeViewBody>
@override
Widget build(BuildContext context) {
final controller = context.watch<HomeController>();
final houseId = controller.currentHouse?.id;
final destinations = <_NavDestination>[
(icon: Icons.assignment_turned_in, label: m.nav.checklists),
(icon: Icons.photo, label: m.nav.photoBoard),
(icon: Icons.insert_drive_file, label: m.nav.notesWall),
];
return Scaffold(
appBar: AppBar(
title: Text(_tabTitle),
actions: [
if (_tabIndex == 0 && houseId != null)
IconButton(
icon: const Icon(Icons.sell_outlined),
tooltip: m.checklists.categories,
onPressed: () {
return LayoutBuilder(
builder: (context, constraints) {
final useRail = constraints.maxWidth >= 720;
final extendedRail = constraints.maxWidth >= 1100;
final body = _buildBody(controller, useRail: useRail);
final appBar = AppBar(
title: Text(_tabTitle),
actions: [
if (_tabIndex == 0 && houseId != null)
IconButton(
icon: const Icon(Icons.sell_outlined),
tooltip: m.checklists.categories,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CategoriesView(houseId: houseId),
),
);
},
),
NotificationsBell(
controller: _notificationsController,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CategoriesView(houseId: houseId),
builder: (_) =>
NotificationsView(controller: _notificationsController),
),
);
},
),
NotificationsBell(
controller: _notificationsController,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
NotificationsView(controller: _notificationsController),
),
);
},
UserMenuButton(
houses: controller.houses,
currentHouse: controller.currentHouse,
onHouseSelected: controller.selectHouse,
onCreateHouse: () => showCreateHouseDialog(context, controller),
onOpenSettings: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsView()));
},
onLogout: widget.onLogout,
),
],
);
if (useRail) {
return Scaffold(
body: SafeArea(
child: Row(
children: [
NavigationRail(
extended: extendedRail,
selectedIndex: _tabIndex,
onDestinationSelected: _goToTab,
labelType: extendedRail
? NavigationRailLabelType.none
: NavigationRailLabelType.all,
leading: _isMacOS ? const SizedBox(height: 24) : null,
destinations: [
for (final d in destinations)
NavigationRailDestination(
icon: Icon(d.icon),
label: Text(d.label),
),
],
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
child: Column(
children: [
appBar,
Expanded(
child: Padding(
padding: EdgeInsetsDirectional.only(
start: _tabIndex == 0 ? 0 : 16,
),
child: body,
),
),
],
),
),
],
),
),
);
}
return Scaffold(
appBar: appBar,
body: body,
bottomNavigationBar: _AnimatedBottomNav(
pageController: _pageController,
currentIndex: _tabIndex,
onTap: _goToTab,
destinations: destinations,
),
UserMenuButton(
houses: controller.houses,
currentHouse: controller.currentHouse,
onHouseSelected: controller.selectHouse,
onCreateHouse: () => showCreateHouseDialog(context, controller),
onOpenSettings: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsView()));
},
onLogout: widget.onLogout,
),
],
),
body: _buildBody(controller),
bottomNavigationBar: _AnimatedBottomNav(
pageController: _pageController,
currentIndex: _tabIndex,
onTap: _goToTab,
destinations: [
(icon: Icons.assignment_turned_in, label: m.nav.checklists),
(icon: Icons.photo, label: m.nav.photoBoard),
(icon: Icons.insert_drive_file, label: m.nav.notesWall),
],
),
);
},
);
}
Widget _buildBody(HomeController controller) {
Widget _buildBody(HomeController controller, {required bool useRail}) {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
@@ -252,15 +314,19 @@ class _HomeViewBodyState extends State<_HomeViewBody>
}
final houseId = controller.currentHouse!.id;
final pages = [
ChecklistsView(key: ValueKey('checklists-$houseId'), houseId: houseId),
PhotoBoardView(key: ValueKey('photos-$houseId'), houseId: houseId),
NotesWallView(key: ValueKey('notes-$houseId'), houseId: houseId),
];
if (useRail) {
return IndexedStack(index: _tabIndex, children: pages);
}
return PageView(
controller: _pageController,
physics: const ClampingScrollPhysics(),
onPageChanged: (i) => setState(() => _tabIndex = i),
children: [
ChecklistsView(key: ValueKey('checklists-$houseId'), houseId: houseId),
PhotoBoardView(key: ValueKey('photos-$houseId'), houseId: houseId),
NotesWallView(key: ValueKey('notes-$houseId'), houseId: houseId),
],
children: pages,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
/// Wraps [child] so that a secondary-tap (right-click on desktop / web)
/// opens [items] as a popup menu anchored at the pointer position.
///
/// Pair with a [PopupMenuButton] / [TileMenuButton] using the same
/// items + onSelected so touch and pointer users get the same affordance.
class ContextMenuRegion<T> extends StatelessWidget {
final List<PopupMenuEntry<T>> Function() itemBuilder;
final ValueChanged<T> onSelected;
final Widget child;
const ContextMenuRegion({
super.key,
required this.itemBuilder,
required this.onSelected,
required this.child,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onSecondaryTapDown: (details) => _show(context, details.globalPosition),
child: child,
);
}
Future<void> _show(BuildContext context, Offset globalPosition) async {
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final value = await showMenu<T>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPosition, globalPosition),
Offset.zero & overlay.size,
),
items: itemBuilder(),
);
if (value != null) onSelected(value);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:pantry/models/photo.dart';
import 'package:pantry/services/auth_service.dart';
import 'package:pantry/services/photo_service.dart';
import 'package:pantry/views/photos/photo_board_controller.dart';
import 'package:pantry/widgets/context_menu_region.dart';
import 'package:pantry/widgets/tile_menu_button.dart';
class FolderTile extends StatelessWidget {
@@ -38,128 +39,104 @@ class FolderTile extends StatelessWidget {
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return GestureDetector(
onTap: () => controller.enterFolder(folder.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: isHovering
? Border.all(color: theme.colorScheme.primary, width: 2)
: null,
),
child: Stack(
children: [
// Photo stack or folder icon — full bleed
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(8),
child: previewPhotos.isNotEmpty
? _buildPhotoStack(theme, headers)
: Center(
child: Icon(
Icons.folder,
size: 56,
color: theme.colorScheme.onSurfaceVariant,
return ContextMenuRegion<String>(
itemBuilder: _menuItems,
onSelected: (value) => _onMenuSelected(context, value),
child: GestureDetector(
onTap: () => controller.enterFolder(folder.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: isHovering
? Border.all(color: theme.colorScheme.primary, width: 2)
: null,
),
child: Stack(
children: [
// Photo stack or folder icon — full bleed
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(8),
child: previewPhotos.isNotEmpty
? _buildPhotoStack(theme, headers)
: Center(
child: Icon(
Icons.folder,
size: 56,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
),
// Count badge
if (photoCount > 0)
// Count badge
if (photoCount > 0)
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(10),
),
child: Text(
m.photoBoard.photoCount(photoCount),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onInverseSurface,
fontWeight: FontWeight.bold,
),
),
),
),
// Folder name with gradient
Positioned(
top: 6,
left: 6,
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
padding: const EdgeInsets.only(
left: 6,
right: 6,
bottom: 6,
top: 20,
),
decoration: BoxDecoration(
color: theme.colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(10),
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withAlpha(180),
Colors.transparent,
],
),
),
child: Text(
m.photoBoard.photoCount(photoCount),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onInverseSurface,
fontWeight: FontWeight.bold,
folder.name,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
),
// Folder name with gradient
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.only(
left: 6,
right: 6,
bottom: 6,
top: 20,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withAlpha(180),
Colors.transparent,
],
),
),
child: Text(
folder.name,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
// Menu
Positioned(
top: 2,
right: 2,
child: TileMenuButton(
items: _menuItems(),
onSelected: (value) => _onMenuSelected(context, value),
),
),
),
// Menu
Positioned(
top: 2,
right: 2,
child: TileMenuButton(
items: [
PopupMenuItem(
value: 'rename',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.photoBoard.renameFolder),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.photoBoard.deleteFolder),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'rename':
_renameFolder(context);
case 'delete':
_deleteFolder(context);
}
},
),
),
],
],
),
),
),
);
@@ -167,6 +144,38 @@ class FolderTile extends StatelessWidget {
);
}
List<PopupMenuEntry<String>> _menuItems() => [
PopupMenuItem(
value: 'rename',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.photoBoard.renameFolder),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.photoBoard.deleteFolder),
],
),
),
];
void _onMenuSelected(BuildContext context, String value) {
switch (value) {
case 'rename':
_renameFolder(context);
case 'delete':
_deleteFolder(context);
}
}
Widget _buildPhotoStack(ThemeData theme, Map<String, String> headers) {
// Show up to 3 photos stacked with slight rotations
final count = previewPhotos.length;

View File

@@ -8,6 +8,7 @@ import 'package:pantry/utils/text_direction.dart';
import 'package:pantry/views/notes/note_detail_view.dart';
import 'package:pantry/views/notes/note_form_view.dart';
import 'package:pantry/views/notes/notes_controller.dart';
import 'package:pantry/widgets/context_menu_region.dart';
class NoteTile extends StatelessWidget {
final Note note;
@@ -28,7 +29,7 @@ class NoteTile extends StatelessWidget {
onTap: () => controller.toggleSelection(note.id),
child: Stack(
children: [
_buildCard(theme, bgColor, textColor),
_buildCard(context, bgColor, textColor),
Positioned(
top: 4,
left: 4,
@@ -59,19 +60,24 @@ class NoteTile extends StatelessWidget {
child: SizedBox(
width: 160,
height: 160,
child: _buildCard(theme, bgColor, textColor),
child: _buildCard(context, bgColor, textColor),
),
),
child: GestureDetector(
onTap: () => _viewNote(context, bgColor, textColor),
child: _buildCard(theme, bgColor, textColor),
child: ContextMenuRegion<String>(
itemBuilder: _menuItems,
onSelected: (value) => _onMenuSelected(context, value),
child: GestureDetector(
onTap: () => _viewNote(context, bgColor, textColor),
child: _buildCard(context, bgColor, textColor),
),
),
);
},
);
}
Widget _buildCard(ThemeData theme, Color bgColor, Color textColor) {
Widget _buildCard(BuildContext context, Color bgColor, Color textColor) {
final theme = Theme.of(context);
final titleDir = detectTextDirection(note.title);
final contentDir = detectTextDirection(note.content);
@@ -102,10 +108,12 @@ class NoteTile extends StatelessWidget {
),
),
),
_NoteMenuButton(
note: note,
controller: controller,
color: textColor,
PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 20, color: textColor),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
itemBuilder: (_) => _menuItems(),
onSelected: (value) => _onMenuSelected(context, value),
),
],
),
@@ -210,56 +218,37 @@ class NoteTile extends StatelessWidget {
final luminance = bg.computeLuminance();
return luminance > 0.5 ? Colors.black87 : Colors.white;
}
}
class _NoteMenuButton extends StatelessWidget {
final Note note;
final NotesController controller;
final Color color;
List<PopupMenuEntry<String>> _menuItems() => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.notesWall.editNote),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.common.delete),
],
),
),
];
const _NoteMenuButton({
required this.note,
required this.controller,
required this.color,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 20, color: color),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onSelected: (value) {
switch (value) {
case 'edit':
_editNote(context);
case 'delete':
_confirmDelete(context);
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.notesWall.editNote),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.common.delete),
],
),
),
],
);
void _onMenuSelected(BuildContext context, String value) {
switch (value) {
case 'edit':
_editNote(context);
case 'delete':
_confirmDelete(context);
}
}
void _editNote(BuildContext context) {

View File

@@ -7,6 +7,7 @@ import 'package:pantry/services/auth_service.dart';
import 'package:pantry/services/photo_service.dart';
import 'package:pantry/views/photos/photo_board_controller.dart';
import 'package:pantry/views/photos/photo_detail_view.dart';
import 'package:pantry/widgets/context_menu_region.dart';
import 'package:pantry/widgets/tile_menu_button.dart';
class PhotoTile extends StatelessWidget {
@@ -131,91 +132,102 @@ class PhotoTile extends StatelessWidget {
Uri uri,
Map<String, String> headers,
) {
return GestureDetector(
onTap: () => _showPhotoDetail(context, uri, headers),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: uri.toString(),
httpHeaders: headers,
fit: BoxFit.cover,
errorWidget: (_, _, _) => Container(
color: theme.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image_outlined, size: 32),
),
),
Positioned(
top: 2,
right: 2,
child: TileMenuButton(
items: [
PopupMenuItem(
value: 'caption',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.photoBoard.caption),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.common.delete),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'caption':
_editCaption(context);
case 'delete':
_confirmDelete(context);
}
},
),
),
if (photo.caption != null && photo.caption!.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black.withAlpha(180), Colors.transparent],
),
),
child: Text(
photo.caption!,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
return ContextMenuRegion<String>(
itemBuilder: _menuItems,
onSelected: (value) => _onMenuSelected(context, value),
child: GestureDetector(
onTap: () => _showPhotoDetail(context, uri, headers),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: uri.toString(),
httpHeaders: headers,
fit: BoxFit.cover,
errorWidget: (_, _, _) => Container(
color: theme.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image_outlined, size: 32),
),
),
],
Positioned(
top: 2,
right: 2,
child: TileMenuButton(
items: _menuItems(),
onSelected: (value) => _onMenuSelected(context, value),
),
),
if (photo.caption != null && photo.caption!.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withAlpha(180),
Colors.transparent,
],
),
),
child: Text(
photo.caption!,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
);
}
List<PopupMenuEntry<String>> _menuItems() => [
PopupMenuItem(
value: 'caption',
child: Row(
children: [
const Icon(Icons.edit, size: 18),
const SizedBox(width: 8),
Text(m.photoBoard.caption),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 18),
const SizedBox(width: 8),
Text(m.common.delete),
],
),
),
];
void _onMenuSelected(BuildContext context, String value) {
switch (value) {
case 'caption':
_editCaption(context);
case 'delete':
_confirmDelete(context);
}
}
void _showPhotoDetail(
BuildContext context,
Uri previewUri,

View File

@@ -8,6 +8,13 @@ class MainFlutterWindow: NSWindow {
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)
// Hide title bar but keep traffic-light buttons; let Flutter content
// draw under the chrome.
self.titleVisibility = .hidden
self.titlebarAppearsTransparent = true
self.styleMask.insert(.fullSizeContentView)
self.isMovableByWindowBackground = true
RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib()