mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
feat: improve ui for larger devices
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
41
lib/widgets/context_menu_region.dart
Normal file
41
lib/widgets/context_menu_region.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user