Skip to content

Commit 6f4239a

Browse files
MrMisticMrMistic
authored andcommitted
Add sticker sending support (folder, save-as-sticker, picker, send path)
- Add stickers directory in filesystem_service (Android external storage) - Add saveAsSticker() method in attachments_service - Add SaveAsSticker action to details_menu_action enum - Add Save as Sticker button to fullscreen image viewer (both skins) - Add Save as Sticker to message long-press popup (image attachments) - Create StickerPicker widget with grid UI, HEIC support, empty state - Integrate sticker picker into attachment picker message wheel - Wire up send path: isStickerSend flag flows through SendAnimation to set balloonBundleId, then rustpush_service detects it to build PartExtension.sticker + ExtensionApp with UserGenerated bundle ID - Fix fullscreen_image NavigationBar index mapping (dynamic action list)
1 parent fe7400b commit 6f4239a

11 files changed

Lines changed: 483 additions & 18 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart';
4+
import 'package:bluebubbles/helpers/helpers.dart';
5+
import 'package:bluebubbles/database/models.dart';
6+
import 'package:bluebubbles/services/services.dart';
7+
import 'package:bluebubbles/utils/logger/logger.dart';
8+
import 'package:flutter/cupertino.dart';
9+
import 'package:flutter/foundation.dart';
10+
import 'package:flutter/material.dart';
11+
import 'package:get/get.dart';
12+
import 'package:mime_type/mime_type.dart';
13+
import 'package:path/path.dart' hide context;
14+
import 'package:universal_io/io.dart';
15+
16+
class StickerPicker extends StatefulWidget {
17+
StickerPicker({
18+
super.key,
19+
required this.controller,
20+
});
21+
final ConversationViewController controller;
22+
23+
@override
24+
State<StickerPicker> createState() => _StickerPickerState();
25+
}
26+
27+
class _StickerPickerState extends OptimizedState<StickerPicker> {
28+
List<File> _stickers = [];
29+
bool _loading = true;
30+
31+
@override
32+
void initState() {
33+
super.initState();
34+
loadStickers();
35+
}
36+
37+
Future<void> loadStickers() async {
38+
try {
39+
final stickerDir = await fs.stickersDirectory;
40+
final dir = Directory(stickerDir);
41+
if (await dir.exists()) {
42+
final entities = dir.listSync();
43+
_stickers = entities
44+
.whereType<File>()
45+
.where((f) {
46+
final mimeType = mime(f.path);
47+
return mimeType != null && mimeType.startsWith('image/');
48+
})
49+
.toList();
50+
// Sort by most recently modified first
51+
_stickers.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
52+
}
53+
} catch (e) {
54+
Logger.error('Failed to load stickers', error: e);
55+
}
56+
_loading = false;
57+
setState(() {});
58+
}
59+
60+
@override
61+
Widget build(BuildContext context) {
62+
if (_loading) {
63+
return SizedBox(
64+
height: 300,
65+
child: Center(child: buildProgressIndicator(context)),
66+
);
67+
}
68+
69+
if (_stickers.isEmpty) {
70+
return SizedBox(
71+
height: 300,
72+
child: Center(
73+
child: Padding(
74+
padding: const EdgeInsets.all(20.0),
75+
child: Column(
76+
mainAxisAlignment: MainAxisAlignment.center,
77+
children: [
78+
Icon(
79+
iOS ? CupertinoIcons.smiley : Icons.emoji_emotions_outlined,
80+
size: 48,
81+
color: context.theme.colorScheme.outline,
82+
),
83+
const SizedBox(height: 12),
84+
Text(
85+
'No stickers saved yet',
86+
style: context.theme.textTheme.bodyLarge?.copyWith(
87+
color: context.theme.colorScheme.outline,
88+
),
89+
),
90+
const SizedBox(height: 4),
91+
Text(
92+
'Save images as stickers from the attachment viewer,\nor add image files to the stickers folder.',
93+
textAlign: TextAlign.center,
94+
style: context.theme.textTheme.bodySmall?.copyWith(
95+
color: context.theme.colorScheme.outline,
96+
),
97+
),
98+
],
99+
),
100+
),
101+
),
102+
);
103+
}
104+
105+
return SizedBox(
106+
height: 300,
107+
child: Padding(
108+
padding: const EdgeInsets.all(10.0),
109+
child: CustomScrollView(
110+
scrollDirection: Axis.horizontal,
111+
slivers: [
112+
SliverGrid(
113+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
114+
crossAxisCount: 2,
115+
crossAxisSpacing: 10,
116+
mainAxisSpacing: 10,
117+
),
118+
delegate: SliverChildBuilderDelegate(
119+
childCount: _stickers.length,
120+
(context, index) {
121+
return _StickerPickerFile(
122+
file: _stickers[index],
123+
controller: widget.controller,
124+
onTap: () async {
125+
final file = _stickers[index];
126+
final bytes = await file.readAsBytes();
127+
final name = basename(file.path);
128+
129+
// Check if already selected — deselect
130+
if (widget.controller.pickedAttachments.firstWhereOrNull(
131+
(e) => e.path == file.path) !=
132+
null) {
133+
widget.controller.pickedAttachments
134+
.removeWhere((e) => e.path == file.path);
135+
// Clear sticker flag if no attachments remain
136+
if (widget.controller.pickedAttachments.isEmpty) {
137+
widget.controller.isStickerSend = false;
138+
}
139+
} else {
140+
widget.controller.pickedAttachments.add(PlatformFile(
141+
path: file.path,
142+
name: name,
143+
size: bytes.length,
144+
));
145+
widget.controller.isStickerSend = true;
146+
}
147+
},
148+
);
149+
},
150+
),
151+
),
152+
],
153+
),
154+
),
155+
);
156+
}
157+
}
158+
159+
class _StickerPickerFile extends StatefulWidget {
160+
_StickerPickerFile({
161+
required this.file,
162+
required this.controller,
163+
required this.onTap,
164+
});
165+
final File file;
166+
final ConversationViewController controller;
167+
final Function() onTap;
168+
169+
@override
170+
State<_StickerPickerFile> createState() => _StickerPickerFileState();
171+
}
172+
173+
class _StickerPickerFileState extends OptimizedState<_StickerPickerFile>
174+
with AutomaticKeepAliveClientMixin {
175+
Uint8List? image;
176+
177+
@override
178+
void initState() {
179+
super.initState();
180+
load();
181+
}
182+
183+
Future<void> load() async {
184+
try {
185+
final path = widget.file.path;
186+
final mimeType = mime(path);
187+
if (mimeType == 'image/heic' ||
188+
mimeType == 'image/heif' ||
189+
mimeType == 'image/tif' ||
190+
mimeType == 'image/tiff') {
191+
final fakeAttachment = Attachment(
192+
transferName: path,
193+
mimeType: mimeType!,
194+
);
195+
image = await as.loadAndGetProperties(fakeAttachment,
196+
actualPath: path, onlyFetchData: true, isPreview: true);
197+
} else {
198+
image = await widget.file.readAsBytes();
199+
}
200+
setState(() {});
201+
} catch (e) {
202+
Logger.error('Failed to load sticker thumbnail', error: e);
203+
}
204+
}
205+
206+
@override
207+
Widget build(BuildContext context) {
208+
super.build(context);
209+
return Obx(() {
210+
bool containsThis = widget.controller.pickedAttachments
211+
.firstWhereOrNull((e) => e.path == widget.file.path) !=
212+
null;
213+
return AnimatedContainer(
214+
duration: const Duration(milliseconds: 250),
215+
margin: EdgeInsets.all(containsThis ? 10 : 0),
216+
decoration: BoxDecoration(
217+
borderRadius: BorderRadius.circular(10),
218+
),
219+
clipBehavior: Clip.antiAlias,
220+
child: InkWell(
221+
borderRadius: BorderRadius.circular(10),
222+
onTap: widget.onTap,
223+
child: Stack(
224+
alignment: Alignment.center,
225+
children: <Widget>[
226+
if (image != null)
227+
Image.memory(
228+
image!,
229+
fit: BoxFit.cover,
230+
width: 150,
231+
height: 150,
232+
cacheWidth: 300,
233+
frameBuilder:
234+
(context, child, frame, wasSynchronouslyLoaded) {
235+
if (frame == null) {
236+
return Positioned.fill(
237+
child: Container(
238+
color: context.theme.colorScheme.properSurface,
239+
),
240+
);
241+
} else {
242+
return child;
243+
}
244+
},
245+
),
246+
if (image == null)
247+
Positioned.fill(
248+
child: Container(
249+
color: context.theme.colorScheme.properSurface,
250+
alignment: Alignment.center,
251+
child: buildProgressIndicator(context),
252+
),
253+
),
254+
if (containsThis)
255+
Container(
256+
decoration: BoxDecoration(
257+
shape: BoxShape.circle,
258+
color: context.theme.colorScheme.primary),
259+
child: Padding(
260+
padding: const EdgeInsets.all(5.0),
261+
child: Icon(
262+
iOS ? CupertinoIcons.check_mark : Icons.check,
263+
color: context.theme.colorScheme.onPrimary,
264+
size: 18,
265+
),
266+
),
267+
),
268+
],
269+
),
270+
),
271+
);
272+
});
273+
}
274+
275+
@override
276+
bool get wantKeepAlive => true;
277+
}

lib/app/layouts/conversation_view/widgets/media_picker/text_field_attachment_picker.dart

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import 'package:permission_handler/permission_handler.dart';
2727
import 'package:photo_manager/photo_manager.dart';
2828
import 'package:collection/collection.dart';
2929
import 'package:bluebubbles/helpers/types/constants.dart' as constants;
30+
import 'package:bluebubbles/app/layouts/conversation_view/widgets/media_picker/sticker_picker.dart';
3031

3132
class AttachmentPicker extends StatefulWidget {
3233
AttachmentPicker({
@@ -47,6 +48,7 @@ class AttachmentPickerState extends OptimizedState<AttachmentPicker> {
4748
List<Map<String, dynamic>> iconsList = [];
4849

4950
App? currentApp;
51+
bool showStickerPicker = false;
5052

5153
void generateIcons() {
5254
iconsList = [
@@ -277,6 +279,15 @@ class AttachmentPickerState extends OptimizedState<AttachmentPicker> {
277279
}
278280
}
279281
},
282+
{
283+
"icon": iOS ? CupertinoIcons.smiley : Icons.emoji_emotions_outlined,
284+
"text": "Stickers",
285+
"handle": () {
286+
setState(() {
287+
showStickerPicker = true;
288+
});
289+
}
290+
},
280291
];
281292

282293
if(!controller.chat.isIMessage) return;
@@ -386,6 +397,39 @@ class AttachmentPickerState extends OptimizedState<AttachmentPicker> {
386397

387398
@override
388399
Widget build(BuildContext context) {
400+
if (showStickerPicker) {
401+
return Stack(
402+
children: [
403+
StickerPicker(controller: controller),
404+
Positioned(
405+
top: 5,
406+
left: 5,
407+
child: GestureDetector(
408+
onTap: () {
409+
setState(() {
410+
showStickerPicker = false;
411+
// Clear sticker state so regular photos don't send as stickers
412+
controller.isStickerSend = false;
413+
controller.pickedAttachments.clear();
414+
});
415+
},
416+
child: Container(
417+
padding: const EdgeInsets.all(6),
418+
decoration: BoxDecoration(
419+
color: context.theme.colorScheme.properSurface.withOpacity(0.8),
420+
shape: BoxShape.circle,
421+
),
422+
child: Icon(
423+
iOS ? CupertinoIcons.back : Icons.arrow_back,
424+
size: 20,
425+
color: context.theme.colorScheme.properOnSurface,
426+
),
427+
),
428+
),
429+
),
430+
],
431+
);
432+
}
389433
if (currentApp != null) {
390434
return SizedBox(
391435
height: 300,

lib/app/layouts/conversation_view/widgets/message/popup/details_menu_action.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ enum DetailsMenuAction {
3434
Bookmark,
3535
SelectMultiple,
3636
MessageInfo,
37+
SaveAsSticker,
3738
}
3839

3940
class PlatformSupport {
@@ -70,6 +71,7 @@ const Map<DetailsMenuAction, PlatformSupport> _actionPlatformSupport = {
7071
DetailsMenuAction.Bookmark: PlatformSupport(true, true, true, true),
7172
DetailsMenuAction.SelectMultiple: PlatformSupport(true, true, true, true),
7273
DetailsMenuAction.MessageInfo: PlatformSupport(true, true, true, true),
74+
DetailsMenuAction.SaveAsSticker: PlatformSupport(true, true, true, false),
7375
};
7476

7577
const Map<DetailsMenuAction, (IconData, IconData)> _actionToIcon = {
@@ -97,6 +99,7 @@ const Map<DetailsMenuAction, (IconData, IconData)> _actionToIcon = {
9799
DetailsMenuAction.Bookmark: (CupertinoIcons.bookmark, Icons.bookmark_outlined),
98100
DetailsMenuAction.SelectMultiple: (CupertinoIcons.checkmark_square, Icons.check_box_outlined),
99101
DetailsMenuAction.MessageInfo: (CupertinoIcons.info, Icons.info),
102+
DetailsMenuAction.SaveAsSticker: (CupertinoIcons.smiley, Icons.emoji_emotions_outlined),
100103
};
101104

102105
const Map<DetailsMenuAction, String> _actionToText = {
@@ -124,6 +127,7 @@ const Map<DetailsMenuAction, String> _actionToText = {
124127
DetailsMenuAction.Bookmark: "Add/Remove Bookmark",
125128
DetailsMenuAction.SelectMultiple: "Select Multiple",
126129
DetailsMenuAction.MessageInfo: "Message Info",
130+
DetailsMenuAction.SaveAsSticker: "Save as Sticker",
127131
};
128132

129133
class _DetailsMenuActionUtils {

0 commit comments

Comments
 (0)