Skip to content

Commit a09018c

Browse files
committed
feat(ui): enhance profile selection and attachment display with popup menu and thumbnails
- Replace profile navigation page with inline popup menu for quick profile switching in drawer footer - Add profile selection menu with avatar badges and checkmark indicator for active profile - Implement async profile loading in _ActiveProfileButton with loading state management - Update attachment chips to display file thumbnails for images and file type icons for documents - Add file type detection utility to identify images and document types by extension - Replace swap_horiz icon with unfold_more icon for better UX in profile button - Improve visual hierarchy in profile menu with gradient avatars and typography styling - Remove direct navigation dependency to ChatProfilesScreen from drawer_footer.dart - Enhance attachment display with grid layout showing file previews and remove buttons - Add support for common image formats (jpg, jpeg, png, gif, webp, bmp) and document types (pdf, doc, docx, txt, video) - Update pubspec.yaml dependencies to support new UI enhancements
1 parent e60cd33 commit a09018c

13 files changed

Lines changed: 1455 additions & 886 deletions

File tree

lib/features/home/presentation/widgets/drawer_widgets/drawer_footer.dart

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:multigateway/app/translate/tl.dart';
33
import 'package:multigateway/core/profile/profile.dart';
4-
import 'package:multigateway/features/profiles/presentation/profiles_page.dart';
54

65
/// Footer của drawer với profile button và user info
76
class DrawerFooter extends StatelessWidget {
@@ -29,28 +28,127 @@ class DrawerFooter extends StatelessWidget {
2928
}
3029
}
3130

32-
/// Button hiển thị active profile
33-
class _ActiveProfileButton extends StatelessWidget {
31+
/// Button hiển thị active profile với popup menu
32+
class _ActiveProfileButton extends StatefulWidget {
3433
final ChatProfile? selectedProfile;
3534
final VoidCallback? onAgentChanged;
3635

3736
const _ActiveProfileButton({this.selectedProfile, this.onAgentChanged});
3837

38+
@override
39+
State<_ActiveProfileButton> createState() => _ActiveProfileButtonState();
40+
}
41+
42+
class _ActiveProfileButtonState extends State<_ActiveProfileButton> {
43+
List<ChatProfile> _profiles = [];
44+
bool _isLoading = true;
45+
46+
@override
47+
void initState() {
48+
super.initState();
49+
_loadProfiles();
50+
}
51+
52+
Future<void> _loadProfiles() async {
53+
final storage = await ChatProfileStorage.init();
54+
if (!mounted) return;
55+
setState(() {
56+
_profiles = storage.getItems();
57+
_isLoading = false;
58+
});
59+
}
60+
61+
Future<void> _selectProfile(ChatProfile profile) async {
62+
final storage = await ChatProfileStorage.init();
63+
await storage.setSelectedProfileId(profile.id);
64+
widget.onAgentChanged?.call();
65+
}
66+
67+
void _showProfileMenu(BuildContext context) {
68+
final colorScheme = Theme.of(context).colorScheme;
69+
final RenderBox button = context.findRenderObject() as RenderBox;
70+
final RenderBox overlay =
71+
Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
72+
final buttonPosition = button.localToGlobal(Offset.zero, ancestor: overlay);
73+
74+
showMenu<ChatProfile>(
75+
context: context,
76+
position: RelativeRect.fromLTRB(
77+
buttonPosition.dx,
78+
buttonPosition.dy - (_profiles.length * 56.0) - 16,
79+
buttonPosition.dx + button.size.width,
80+
buttonPosition.dy,
81+
),
82+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
83+
color: colorScheme.surface,
84+
elevation: 8,
85+
items: _profiles.map((profile) {
86+
final isSelected = profile.id == widget.selectedProfile?.id;
87+
return PopupMenuItem<ChatProfile>(
88+
value: profile,
89+
child: Row(
90+
children: [
91+
Container(
92+
width: 32,
93+
height: 32,
94+
decoration: BoxDecoration(
95+
gradient: LinearGradient(
96+
begin: Alignment.topLeft,
97+
end: Alignment.bottomRight,
98+
colors: [colorScheme.tertiary, colorScheme.primary],
99+
),
100+
borderRadius: BorderRadius.circular(16),
101+
),
102+
child: Center(
103+
child: Text(
104+
profile.name
105+
.split(' ')
106+
.map((word) => word.isNotEmpty ? word[0] : '')
107+
.take(2)
108+
.join()
109+
.toUpperCase(),
110+
style: TextStyle(
111+
color: colorScheme.onPrimary,
112+
fontSize: 10,
113+
fontWeight: FontWeight.bold,
114+
),
115+
),
116+
),
117+
),
118+
const SizedBox(width: 12),
119+
Expanded(
120+
child: Text(
121+
profile.name,
122+
style: TextStyle(
123+
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
124+
color: isSelected
125+
? colorScheme.primary
126+
: colorScheme.onSurface,
127+
),
128+
maxLines: 1,
129+
overflow: TextOverflow.ellipsis,
130+
),
131+
),
132+
if (isSelected)
133+
Icon(Icons.check_circle, color: colorScheme.primary, size: 20),
134+
],
135+
),
136+
);
137+
}).toList(),
138+
).then((selectedProfile) {
139+
if (selectedProfile != null) {
140+
_selectProfile(selectedProfile);
141+
}
142+
});
143+
}
144+
39145
@override
40146
Widget build(BuildContext context) {
41147
final colorScheme = Theme.of(context).colorScheme;
42-
final profileName = selectedProfile?.name ?? tl('Standard Gateway');
148+
final profileName = widget.selectedProfile?.name ?? tl('Standard Gateway');
43149

44150
return InkWell(
45-
onTap: () async {
46-
final result = await Navigator.push(
47-
context,
48-
MaterialPageRoute(builder: (context) => const ChatProfilesScreen()),
49-
);
50-
if (result == true) {
51-
onAgentChanged?.call();
52-
}
53-
},
151+
onTap: _isLoading ? null : () => _showProfileMenu(context),
54152
borderRadius: BorderRadius.circular(12),
55153
child: Container(
56154
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
@@ -117,7 +215,7 @@ class _ActiveProfileButton extends StatelessWidget {
117215
),
118216
),
119217
Icon(
120-
Icons.swap_horiz,
218+
Icons.unfold_more,
121219
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
122220
size: 20,
123221
),
Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import 'dart:io';
2+
13
import 'package:flutter/material.dart';
24

3-
/// Widget hiển thị danh sách file attachments dưới dạng chips
5+
/// Widget hiển thị danh sách file attachments dưới dạng ô vuông với thumbnail
46
class AttachmentChips extends StatelessWidget {
57
final List<String> attachments;
68
final Function(int index) onRemove;
@@ -11,25 +13,101 @@ class AttachmentChips extends StatelessWidget {
1113
required this.onRemove,
1214
});
1315

16+
bool _isImage(String path) {
17+
final ext = path.toLowerCase().split('.').last;
18+
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].contains(ext);
19+
}
20+
21+
IconData _getFileIcon(String path) {
22+
final ext = path.toLowerCase().split('.').last;
23+
switch (ext) {
24+
case 'pdf':
25+
return Icons.picture_as_pdf;
26+
case 'doc':
27+
case 'docx':
28+
return Icons.description;
29+
case 'txt':
30+
return Icons.text_snippet;
31+
case 'mp4':
32+
case 'mov':
33+
case 'avi':
34+
return Icons.video_file;
35+
case 'mp3':
36+
case 'wav':
37+
return Icons.audio_file;
38+
default:
39+
return Icons.insert_drive_file;
40+
}
41+
}
42+
1443
@override
1544
Widget build(BuildContext context) {
1645
if (attachments.isEmpty) return const SizedBox.shrink();
1746

18-
return Padding(
19-
padding: const EdgeInsets.only(left: 4, right: 4, bottom: 6),
20-
child: Wrap(
21-
spacing: 6,
22-
runSpacing: -8,
23-
children: List.generate(attachments.length, (i) {
24-
final name = attachments[i].split('/').last;
25-
return Chip(
26-
label: Text(name, overflow: TextOverflow.ellipsis),
27-
onDeleted: () => onRemove(i),
28-
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
29-
visualDensity: VisualDensity.compact,
47+
return SizedBox(
48+
height: 72,
49+
child: ListView.separated(
50+
scrollDirection: Axis.horizontal,
51+
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
52+
itemCount: attachments.length,
53+
separatorBuilder: (_, _) => const SizedBox(width: 8),
54+
itemBuilder: (context, i) {
55+
final path = attachments[i];
56+
final isImage = _isImage(path);
57+
58+
return Stack(
59+
children: [
60+
Container(
61+
width: 64,
62+
height: 64,
63+
decoration: BoxDecoration(
64+
borderRadius: BorderRadius.circular(12),
65+
color: Theme.of(context).colorScheme.surfaceContainerHighest,
66+
),
67+
clipBehavior: Clip.antiAlias,
68+
child: isImage
69+
? Image.file(
70+
File(path),
71+
fit: BoxFit.cover,
72+
errorBuilder: (_, _, _) => Icon(
73+
Icons.broken_image,
74+
color: Theme.of(context).colorScheme.onSurfaceVariant,
75+
),
76+
)
77+
: Icon(
78+
_getFileIcon(path),
79+
size: 28,
80+
color: Theme.of(context).colorScheme.onSurfaceVariant,
81+
),
82+
),
83+
Positioned(
84+
top: -4,
85+
right: -4,
86+
child: IconButton(
87+
icon: Container(
88+
padding: const EdgeInsets.all(2),
89+
decoration: BoxDecoration(
90+
color: Theme.of(context).colorScheme.surface,
91+
shape: BoxShape.circle,
92+
),
93+
child: Icon(
94+
Icons.close,
95+
size: 14,
96+
color: Theme.of(context).colorScheme.onSurface,
97+
),
98+
),
99+
onPressed: () => onRemove(i),
100+
padding: EdgeInsets.zero,
101+
constraints: const BoxConstraints(
102+
minWidth: 24,
103+
minHeight: 24,
104+
),
105+
),
106+
),
107+
],
30108
);
31-
}),
109+
},
32110
),
33111
);
34112
}
35-
}
113+
}

0 commit comments

Comments
 (0)