feat: add gallery feature with database integration and rendering capabilities
Build Android App / build (push) Failing after 2m19s

This commit is contained in:
ImBenji
2026-08-25 11:46:33 +01:00
parent b66b73a3c6
commit 8c2212b563
17 changed files with 2902 additions and 885 deletions
+238
View File
@@ -0,0 +1,238 @@
import "dart:typed_data";
import "package:flutter/foundation.dart";
import "package:go_router/go_router.dart";
import "package:intl/intl.dart";
import "package:provider/provider.dart";
import "package:quotegen_client/models/gallery_item.dart";
import "package:quotegen_client/providers/form_provider.dart";
import "package:quotegen_client/providers/gallery_provider.dart";
import "package:quotegen_client/providers/session_provider.dart";
import "package:quotegen_client/widgets/tweet_template_widget.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class GalleryPage extends StatelessWidget {
static GoRoute route = GoRoute(
path: "/gallery",
builder: (context, state) => GalleryPage(),
);
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return Scaffold(
child: Center(child: Text("Gallery not available on web")),
);
}
final gallery = context.watch<GalleryProvider>();
final double topPadding = MediaQuery.of(context).padding.top;
if (gallery.isLoading) {
return Scaffold(
child: Center(child: CircularProgressIndicator()),
);
}
if (gallery.items.isEmpty) {
return Scaffold(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.galleryHorizontal,
size: 48,
),
Gap(12),
Text("No saved posts yet").large.semiBold,
Gap(4),
Text("Use the bookmark button in the editor to save a post").small.muted,
],
),
),
);
}
return Scaffold(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Gap(topPadding + 14),
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 14),
sliver: SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 0.75,
),
itemCount: gallery.items.length,
itemBuilder: (context, index) {
final item = gallery.items[index];
return _GalleryCard(
item: item,
avatarBytes: gallery.decodeAvatar(item),
postImageBytes: gallery.decodePostImage(item),
);
},
),
),
SliverToBoxAdapter(
child: Gap(120),
),
],
),
);
}
}
class _GalleryCard extends StatelessWidget {
final GalleryItem item;
final Uint8List? avatarBytes;
final Uint8List? postImageBytes;
const _GalleryCard({
required this.item,
this.avatarBytes,
this.postImageBytes,
});
@override
Widget build(BuildContext context) {
final ts = DateTime.fromMillisecondsSinceEpoch(item.createdAt);
final formatted = DateFormat("MMM d, yyyy").format(ts);
final engagement = (item.replies == "" && item.retweets == "" && item.likes == "" && item.views == "")
? null
: {
"replies": int.tryParse(item.replies.replaceAll(",", "")) ?? 0,
"retweets": int.tryParse(item.retweets.replaceAll(",", "")) ?? 0,
"likes": int.tryParse(item.likes.replaceAll(",", "")) ?? 0,
"views": int.tryParse(item.views.replaceAll(",", "")) ?? 0,
};
return GestureDetector(
onTap: () => _onTap(context),
child: OutlinedContainer(
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: IgnorePointer(
child: TweetTemplateWidget(
displayName: item.displayName,
username: item.handle,
isVerified: item.verified,
text: item.content,
timestamp: DateTime.fromMillisecondsSinceEpoch(item.timestamp * 1000),
avatarBytes: avatarBytes,
imageBytes: postImageBytes,
engagement: engagement,
),
),
),
Divider(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.displayName.isEmpty ? "Untitled" : item.displayName).small.semiBold,
Text(formatted).xSmall.muted,
],
),
),
IconButton.destructive(
icon: Icon(LucideIcons.trash2, size: 14),
onPressed: () {
context.read<GalleryProvider>().deleteItem(item.id);
},
),
],
),
),
],
),
),
);
}
Future<void> _onTap(BuildContext context) async {
final form = context.read<FormProvider>();
final session = context.read<SessionProvider>();
final gallery = context.read<GalleryProvider>();
bool hasContent = form.displayName.isNotEmpty ||
form.handle.isNotEmpty ||
form.content.isNotEmpty ||
form.avatarImage != null ||
form.postImage != null;
if (hasContent) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text("Load this post?"),
content: Text("Your current editor content will be replaced."),
actions: [
Button.outline(
child: Text("Cancel"),
onPressed: () => Navigator.of(ctx).pop(false),
),
Button.primary(
child: Text("Load"),
onPressed: () => Navigator.of(ctx).pop(true),
),
],
),
);
if (confirmed != true) return;
}
// load item fields into form
form.setDisplayName(item.displayName);
form.setHandle(item.handle);
form.setContent(item.content);
form.setVerified(item.verified);
final dt = DateTime.fromMillisecondsSinceEpoch(item.timestamp * 1000);
form.setDate(DateTime(dt.year, dt.month, dt.day));
form.setTime(TimeOfDay(hour: dt.hour, minute: dt.minute));
form.setAvatarImage(gallery.decodeAvatar(item));
form.setPostImage(gallery.decodePostImage(item));
form.setReplies(item.replies);
form.setRetweets(item.retweets);
form.setLikes(item.likes);
form.setViews(item.views);
await session.initializeSession(form.buildFullRequest());
if (context.mounted) {
context.go("/");
}
}
}
+874 -847
View File
File diff suppressed because it is too large Load Diff