Files
enaklo-flutter/lib/presentation/pages/community/community_page.dart
T
efrilmandClaude Opus 5.5 bc751246d8 Tab Community, tombol Bayar di navbar, logo placeholder, fix crash PIN
- Bottom navbar: Home, Pesanan, Bayar (tombol tengah ke kode bayar),
  Community, Profil; menu EnakCommunity di beranda pindah ke tab
- Image placeholder: logo Enaklo diperbesar, ilustrasi gelas dihapus
- Fix assertion Navigator saat PIN gagal karena error non-PIN: flushbar
  kini tampil lewat onFailure setelah sheet PIN tertutup (bayar, tukar,
  transfer)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 02:05:49 +07:00

511 lines
16 KiB
Dart

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/community_sample_data.dart';
import '../../components/assets/assets.gen.dart';
import '../../components/toast/flushbar.dart';
import '../../router/app_router.gr.dart';
import 'community_ui.dart';
import 'widgets/community_compose_sheet.dart';
import 'widgets/community_event_card.dart';
import 'widgets/community_group_card.dart';
import 'widgets/community_post_card.dart';
enum _CommunityTab { forYou, groups, events }
@RoutePage()
class CommunityPage extends StatefulWidget {
const CommunityPage({super.key});
@override
State<CommunityPage> createState() => _CommunityPageState();
}
class _CommunityPageState extends State<CommunityPage> {
final List<CommunityPost> _posts = [...communityPosts];
final Set<String> _goingEventIds = {};
_CommunityTab _tab = _CommunityTab.forYou;
void _openGroup(CommunityGroup group) =>
context.router.push(CommunityDetailRoute(group: group));
void _openPost(CommunityPost post) =>
context.router.push(CommunityPostDetailRoute(post: post));
Future<void> _compose() async {
final joined = communityGroups
.where((g) => joinedCommunityIds.value.contains(g.id))
.toList();
if (joined.isEmpty) {
AppFlushbar.showError(context, 'Gabung komunitas dulu untuk posting');
setState(() => _tab = _CommunityTab.groups);
return;
}
final post = await CommunityComposeSheet.show(context, groups: joined);
if (post == null || !mounted) return;
setState(() {
_posts.insert(0, post);
_tab = _CommunityTab.forYou;
});
AppFlushbar.showSuccess(context, 'Postingan kamu sudah terbit');
}
void _toggleEvent(CommunityEvent event) {
final going = !_goingEventIds.contains(event.id);
setState(() {
going ? _goingEventIds.add(event.id) : _goingEventIds.remove(event.id);
});
if (going) {
AppFlushbar.showSuccess(context, 'Kamu terdaftar di ${event.title}');
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Set<String>>(
valueListenable: joinedCommunityIds,
builder: (context, joinedIds, _) {
return Scaffold(
backgroundColor: CommunityColor.background,
floatingActionButton: AnimatedScale(
scale: _tab == _CommunityTab.events ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: FloatingActionButton.extended(
onPressed: _compose,
elevation: 2,
backgroundColor: AppColor.primary,
foregroundColor: AppColor.white,
shape: const StadiumBorder(),
icon: const Icon(Icons.edit_rounded, size: 20),
label: Text(
'Tulis',
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w700,
),
),
),
),
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
automaticallyImplyLeading: false,
backgroundColor: CommunityColor.background,
title: const Text('EnakCommunity'),
actions: [
IconButton(
onPressed: () =>
context.router.push(const NotificationRoute()),
icon: const Icon(
Icons.notifications_none_rounded,
color: AppColor.textPrimary,
),
),
const SizedBox(width: 4),
],
),
SliverToBoxAdapter(child: _buildHero(joinedIds.length)),
SliverPersistentHeader(
pinned: true,
delegate: _TabHeaderDelegate(
selected: _tab,
onChanged: (tab) => setState(() => _tab = tab),
),
),
...switch (_tab) {
_CommunityTab.forYou => _buildForYou(joinedIds),
_CommunityTab.groups => _buildGroups(joinedIds),
_CommunityTab.events => _buildEvents(),
},
const SliverToBoxAdapter(child: SizedBox(height: 96)),
],
),
);
},
);
}
Widget _buildHero(int joinedCount) {
final myPostCount = _posts
.where((p) => p.author.id == currentMember.id)
.length;
return Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
padding: const EdgeInsets.fromLTRB(20, 20, 16, 16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [CommunityColor.blush, Color(0xFFFFF6EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Halo, Foodie! 👋',
style: AppStyle.xxl.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text(
'Berbagi cerita kuliner, ikut event seru, dan temukan '
'teman makan baru.',
style: AppStyle.sm.copyWith(
height: 1.45,
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 8),
Assets.icons.enakcommunityIcon.image(width: 72, height: 72),
],
),
const SizedBox(height: 14),
Row(
children: [
_HeroStat(value: '$joinedCount', label: 'Komunitas'),
const SizedBox(width: 8),
_HeroStat(value: '$myPostCount', label: 'Postingan'),
const SizedBox(width: 8),
_HeroStat(value: '${_goingEventIds.length}', label: 'Event'),
],
),
const SizedBox(height: 14),
Material(
color: AppColor.white,
borderRadius: BorderRadius.circular(999),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: _compose,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
child: Row(
children: [
const CommunityAvatar(member: currentMember, size: 32),
const SizedBox(width: 10),
Expanded(
child: Text(
'Apa yang kamu makan hari ini?',
style: AppStyle.sm.copyWith(
color: CommunityColor.muted,
fontWeight: FontWeight.w600,
),
),
),
const Icon(
Icons.edit_note_rounded,
color: AppColor.primary,
),
],
),
),
),
),
],
),
);
}
List<Widget> _buildForYou(Set<String> joinedIds) {
return [
SliverToBoxAdapter(
child: CommunitySectionTitle(
title: 'Komunitas Populer',
actionLabel: 'Lihat semua',
onAction: () => setState(() => _tab = _CommunityTab.groups),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 176,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: communityGroups.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final group = communityGroups[index];
return CommunityGroupCard(
group: group,
isJoined: joinedIds.contains(group.id),
onTap: () => _openGroup(group),
onJoin: () => toggleJoinCommunity(group.id),
);
},
),
),
),
const SliverToBoxAdapter(
child: CommunitySectionTitle(title: 'Obrolan Terbaru'),
),
SliverList.builder(
itemCount: _posts.length,
itemBuilder: (context, index) {
final post = _posts[index];
return CommunityPostCard(
key: ValueKey(post.id),
post: post,
onTap: () => _openPost(post),
onGroupTap: () => _openGroup(post.group),
);
},
),
];
}
List<Widget> _buildGroups(Set<String> joinedIds) {
final joined = communityGroups
.where((g) => joinedIds.contains(g.id))
.toList();
final discover = communityGroups
.where((g) => !joinedIds.contains(g.id))
.toList();
Widget tile(CommunityGroup group) => CommunityGroupTile(
key: ValueKey(group.id),
group: group,
isJoined: joinedIds.contains(group.id),
onTap: () => _openGroup(group),
onJoin: () => toggleJoinCommunity(group.id),
);
return [
const SliverToBoxAdapter(
child: CommunitySectionTitle(title: 'Komunitas Saya'),
),
if (joined.isEmpty)
const SliverToBoxAdapter(
child: _SoftEmpty(
emoji: '🤝',
title: 'Belum gabung komunitas',
message: 'Pilih komunitas di bawah yang sesuai seleramu.',
),
)
else
SliverList.list(children: joined.map(tile).toList()),
if (discover.isNotEmpty) ...[
const SliverToBoxAdapter(
child: CommunitySectionTitle(title: 'Jelajahi Komunitas'),
),
SliverList.list(children: discover.map(tile).toList()),
],
];
}
List<Widget> _buildEvents() {
return [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
const Text('🎁', style: TextStyle(fontSize: 24)),
const SizedBox(width: 12),
Expanded(
child: Text(
'Hadiri event komunitas dan kumpulkan EnakPoint & EnakCoin '
'tambahan!',
style: AppStyle.sm.copyWith(
height: 1.4,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
SliverList.builder(
itemCount: communityEvents.length,
itemBuilder: (context, index) {
final event = communityEvents[index];
return CommunityEventCard(
event: event,
isGoing: _goingEventIds.contains(event.id),
onToggle: () => _toggleEvent(event),
);
},
),
];
}
}
class _HeroStat extends StatelessWidget {
const _HeroStat({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColor.white.withOpacity(0.7),
borderRadius: BorderRadius.circular(999),
),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '$value ',
style: const TextStyle(
color: AppColor.primary,
fontWeight: FontWeight.w700,
),
),
TextSpan(text: label),
],
),
style: AppStyle.xs.copyWith(fontWeight: FontWeight.w600),
),
);
}
}
class _SoftEmpty extends StatelessWidget {
const _SoftEmpty({
required this.emoji,
required this.title,
required this.message,
});
final String emoji;
final String title;
final String message;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
decoration: communityCardDecoration(),
child: Column(
children: [
Text(emoji, style: const TextStyle(fontSize: 32)),
const SizedBox(height: 8),
Text(title, style: AppStyle.md.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(
message,
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(color: CommunityColor.muted),
),
],
),
);
}
}
/// Tab bergaya pil lembut yang menempel saat di-scroll.
class _TabHeaderDelegate extends SliverPersistentHeaderDelegate {
_TabHeaderDelegate({required this.selected, required this.onChanged});
final _CommunityTab selected;
final ValueChanged<_CommunityTab> onChanged;
static const double _height = 64;
static const _labels = {
_CommunityTab.forYou: 'Untukmu',
_CommunityTab.groups: 'Komunitas',
_CommunityTab.events: 'Event',
};
@override
double get minExtent => _height;
@override
double get maxExtent => _height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
const tabs = _CommunityTab.values;
final index = tabs.indexOf(selected);
return Container(
color: CommunityColor.background,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(999),
boxShadow: communitySoftShadow,
),
child: Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
alignment: Alignment(-1 + 2 * index / (tabs.length - 1), 0),
child: FractionallySizedBox(
widthFactor: 1 / tabs.length,
heightFactor: 1,
child: Container(
decoration: BoxDecoration(
color: CommunityColor.blush,
borderRadius: BorderRadius.circular(999),
),
),
),
),
Row(
children: tabs.map((tab) {
final isSelected = tab == selected;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(tab),
child: Center(
child: Text(
_labels[tab]!,
style: AppStyle.sm.copyWith(
color: isSelected
? AppColor.primary
: CommunityColor.muted,
fontWeight: isSelected
? FontWeight.w700
: FontWeight.w600,
),
),
),
),
);
}).toList(),
),
],
),
),
);
}
@override
bool shouldRebuild(_TabHeaderDelegate oldDelegate) =>
oldDelegate.selected != selected;
}