From bc751246d88401b4ebbc22f5653c3a22124cf135 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 1 Oct 2026 02:05:49 +0700 Subject: [PATCH] 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 --- lib/presentation/components/image/image.dart | 1 - .../components/image/image_placeholder.dart | 139 ++++-------------- .../pages/community/community_page.dart | 1 + lib/presentation/pages/main/main_page.dart | 31 +++- .../pages/home/widgets/service_section.dart | 10 ++ .../pages/main/widgets/bottom_navbar.dart | 70 ++++++++- .../pages/pin/widgets/pin_sheet.dart | 18 ++- .../pages/wallet/exchange_page.dart | 6 +- .../pages/wallet/payment_code_page.dart | 9 +- .../pages/wallet/transfer_page.dart | 6 +- lib/presentation/router/app_router.dart | 4 +- 11 files changed, 151 insertions(+), 144 deletions(-) diff --git a/lib/presentation/components/image/image.dart b/lib/presentation/components/image/image.dart index c61ef77..1c74ffd 100644 --- a/lib/presentation/components/image/image.dart +++ b/lib/presentation/components/image/image.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'dart:math' as math; -import '../../../common/theme/theme.dart'; import '../assets/assets.gen.dart'; part 'image_placeholder.dart'; diff --git a/lib/presentation/components/image/image_placeholder.dart b/lib/presentation/components/image/image_placeholder.dart index ff567ec..5ee3f49 100644 --- a/lib/presentation/components/image/image_placeholder.dart +++ b/lib/presentation/components/image/image_placeholder.dart @@ -22,7 +22,7 @@ class ImagePlaceholder extends StatelessWidget { final containerWidth = width ?? constraints.maxWidth; final containerHeight = height ?? constraints.maxHeight; - // Calculate the minimum dimension to determine if we should show simple or detailed version + // Logo mengikuti sisi terpendek placeholder final minDimension = math.min( containerWidth == double.infinity ? containerHeight : containerWidth, containerHeight == double.infinity ? containerWidth : containerHeight, @@ -42,129 +42,44 @@ class ImagePlaceholder extends StatelessWidget { ) : null, ), - child: Center( - child: minDimension < 150 - ? _buildSimpleVersion(minDimension) - : _buildDetailedVersion(minDimension), - ), + child: Center(child: _buildLogo(context, minDimension)), ); }, ); } - // Simple version for small sizes (< 100px) - Widget _buildSimpleVersion(double size) { - final iconSize = (size * 0.4).clamp(16.0, 32.0); - final fontSize = (size * 0.12).clamp(8.0, 12.0); - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: iconSize * 1.5, - height: iconSize * 1.5, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(iconSize * 0.75), - ), - child: Center( - child: Assets.images.logo.image( - width: iconSize, - height: iconSize, - fit: BoxFit.contain, - ), - ), - ), - if (size > 50) ...[ - SizedBox(height: size * 0.05), - Text( - 'Enaklo', - style: TextStyle( - color: AppColor.primary, - fontSize: fontSize, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ); - } - - // Detailed version for larger sizes (>= 100px) - Widget _buildDetailedVersion(double minDimension) { - final scaleFactor = minDimension / 200; // Base scale factor - - // Proportional sizes - final illustrationSize = (120 * scaleFactor).clamp(80.0, 120.0); - final illustrationHeight = (160 * scaleFactor).clamp(100.0, 160.0); - final handWidth = (60 * scaleFactor).clamp(30.0, 60.0); - final handHeight = (80 * scaleFactor).clamp(40.0, 80.0); - final cupWidth = (70 * scaleFactor).clamp(35.0, 70.0); - final cupHeight = (90 * scaleFactor).clamp(45.0, 90.0); - final logoSize = (40 * scaleFactor).clamp(20.0, 40.0); - final fontSize = (12 * scaleFactor).clamp(8.0, 12.0); + Widget _buildLogo(BuildContext context, double minDimension) { + final size = (minDimension * 0.6).clamp(28.0, 140.0); + // Aset logo 1024px; decode seukuran tampilan saja. + final cacheSize = (size * 1.2 * MediaQuery.devicePixelRatioOf(context)) + .round(); return Container( - width: illustrationSize, - height: illustrationHeight, + width: size, + height: size, + padding: EdgeInsets.all(size * 0.04), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(illustrationSize / 2), - ), - child: Stack( - children: [ - // Hand - Positioned( - bottom: illustrationHeight * 0.125, // 20/160 ratio - left: illustrationSize * 0.25, // 30/120 ratio - child: Container( - width: handWidth, - height: handHeight, - decoration: BoxDecoration( - color: const Color(0xFFFFDBB3), - borderRadius: BorderRadius.circular(handWidth / 2), - ), - ), - ), - // Coffee cup - Positioned( - top: illustrationHeight * 0.1875, // 30/160 ratio - left: illustrationSize * 0.208, // 25/120 ratio - child: Container( - width: cupWidth, - height: cupHeight, - decoration: BoxDecoration( - color: const Color(0xFFF4E4BC), - borderRadius: BorderRadius.circular( - math.max(8.0, 10 * scaleFactor), - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Logo - Assets.images.logo.image( - width: logoSize, - height: logoSize, - fit: BoxFit.contain, - ), - SizedBox(height: math.max(4.0, 8 * scaleFactor)), - if (cupHeight > 50) // Only show text if cup is big enough - Text( - 'Enaklo', - style: TextStyle( - color: AppColor.primary, - fontSize: fontSize, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: size * 0.1, + offset: Offset(0, size * 0.03), ), ], ), + child: ClipOval( + // Sudut aset logo berlatar gelap; diperbesar sedikit supaya yang + // terlihat hanya lingkaran merahnya. + child: Transform.scale( + scale: 1.2, + child: Assets.images.logo.image( + fit: BoxFit.cover, + cacheWidth: cacheSize, + ), + ), + ), ); } } diff --git a/lib/presentation/pages/community/community_page.dart b/lib/presentation/pages/community/community_page.dart index f196519..e9d92b9 100644 --- a/lib/presentation/pages/community/community_page.dart +++ b/lib/presentation/pages/community/community_page.dart @@ -93,6 +93,7 @@ class _CommunityPageState extends State { slivers: [ SliverAppBar( pinned: true, + automaticallyImplyLeading: false, backgroundColor: CommunityColor.background, title: const Text('EnakCommunity'), actions: [ diff --git a/lib/presentation/pages/main/main_page.dart b/lib/presentation/pages/main/main_page.dart index 41ef4e8..cf04a2b 100644 --- a/lib/presentation/pages/main/main_page.dart +++ b/lib/presentation/pages/main/main_page.dart @@ -21,6 +21,14 @@ class _MainPageState extends State { context.read().add(const AuthEvent.fetchCurrentUser()); } + /// Kode bayar di kasir; PIN diminta di halamannya sendiri. + void _openPay() { + final isAuthenticated = context.read().state.isAuthenticated; + context.router.push( + isAuthenticated ? const PaymentCodeRoute() : const OnboardingRoute(), + ); + } + @override Widget build(BuildContext context) { return AutoTabsRouter.pageView( @@ -28,15 +36,26 @@ class _MainPageState extends State { HomeRoute(), // VoucherRoute(), OrderRoute(), + CommunityRoute(), ProfileRoute(), ], physics: const NeverScrollableScrollPhysics(), - builder: (context, child, pageController) => Scaffold( - body: child, - bottomNavigationBar: MainBottomNavbar( - tabsRouter: AutoTabsRouter.of(context), - ), - ), + builder: (context, child, pageController) { + // Docked FAB ikut naik ke atas keyboard; sembunyikan saat mengetik. + final isKeyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + + return Scaffold( + body: child, + floatingActionButton: isKeyboardOpen + ? null + : MainPayButton(onPressed: _openPay), + floatingActionButtonLocation: const MainPayButtonLocation(), + bottomNavigationBar: MainBottomNavbar( + tabsRouter: AutoTabsRouter.of(context), + onPayTap: _openPay, + ), + ); + }, ); } } diff --git a/lib/presentation/pages/main/pages/home/widgets/service_section.dart b/lib/presentation/pages/main/pages/home/widgets/service_section.dart index 935eeb7..6e7e2d7 100644 --- a/lib/presentation/pages/main/pages/home/widgets/service_section.dart +++ b/lib/presentation/pages/main/pages/home/widgets/service_section.dart @@ -15,6 +15,7 @@ class _HomeMenu { required this.icon, this.route, this.requiresLogin = false, + this.opensTab = false, }); final String label; @@ -23,6 +24,9 @@ class _HomeMenu { /// `null` berarti fitur belum tersedia. final PageRouteInfo? route; final bool requiresLogin; + + /// [route] adalah tab di bottom navbar; dibuka dengan pindah tab, bukan push. + final bool opensTab; } class HomeServiceSection extends StatelessWidget { @@ -78,6 +82,7 @@ class HomeServiceSection extends StatelessWidget { label: 'EnakCommunity', icon: Assets.icons.enakcommunityIcon, route: const CommunityRoute(), + opensTab: true, ), ]; @@ -94,6 +99,11 @@ class HomeServiceSection extends StatelessWidget { return; } + if (menu.opensTab) { + context.tabsRouter.navigate(route); + return; + } + context.router.push(route); } diff --git a/lib/presentation/pages/main/widgets/bottom_navbar.dart b/lib/presentation/pages/main/widgets/bottom_navbar.dart index 49dd7c4..f54a262 100644 --- a/lib/presentation/pages/main/widgets/bottom_navbar.dart +++ b/lib/presentation/pages/main/widgets/bottom_navbar.dart @@ -1,16 +1,33 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import '../../../../common/theme/theme.dart'; + class MainBottomNavbar extends StatelessWidget { final TabsRouter tabsRouter; - const MainBottomNavbar({super.key, required this.tabsRouter}); + final VoidCallback onPayTap; + const MainBottomNavbar({ + super.key, + required this.tabsRouter, + required this.onPayTap, + }); + + /// Slot tengah hanya tempat label tombol Bayar; lingkarannya + /// [MainPayButton] yang menempel di atas navbar. + static const int _payIndex = 2; @override Widget build(BuildContext context) { + final activeIndex = tabsRouter.activeIndex; + return BottomNavigationBar( - currentIndex: tabsRouter.activeIndex, + currentIndex: activeIndex >= _payIndex ? activeIndex + 1 : activeIndex, onTap: (index) { - tabsRouter.setActiveIndex(index); + if (index == _payIndex) { + onPayTap(); + return; + } + tabsRouter.setActiveIndex(index > _payIndex ? index - 1 : index); }, items: const [ BottomNavigationBarItem( @@ -28,6 +45,16 @@ class MainBottomNavbar extends StatelessWidget { label: 'Pesanan', tooltip: 'Pesanan', ), + BottomNavigationBarItem( + icon: SizedBox(height: 24), + label: 'Bayar', + tooltip: 'Bayar di kasir', + ), + BottomNavigationBarItem( + icon: Icon(Icons.groups), + label: 'Community', + tooltip: 'Community', + ), BottomNavigationBarItem( icon: Icon(Icons.person), label: 'Profil', @@ -37,3 +64,40 @@ class MainBottomNavbar extends StatelessWidget { ); } } + +/// Seperti [FloatingActionButtonLocation.centerDocked], tapi tombolnya lebih +/// menonjol ke atas supaya tidak menempel ke label "Bayar". +class MainPayButtonLocation extends StandardFabLocation + with FabCenterOffsetX, FabDockedOffsetY { + const MainPayButtonLocation(); + + static const double _raise = 10; + + @override + double getOffsetY( + ScaffoldPrelayoutGeometry scaffoldGeometry, + double adjustment, + ) => super.getOffsetY(scaffoldGeometry, adjustment) - _raise; +} + +/// Tombol bulat di tengah navbar, dipasang di [MainPayButtonLocation]. +class MainPayButton extends StatelessWidget { + final VoidCallback onPressed; + const MainPayButton({super.key, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + onPressed: onPressed, + // Halaman tab (mis. Community) juga punya FAB; tanpa hero supaya tag + // default tidak bentrok saat pindah halaman. + heroTag: null, + tooltip: 'Bayar di kasir', + elevation: 4, + backgroundColor: AppColor.primary, + foregroundColor: AppColor.white, + shape: const CircleBorder(), + child: const Icon(Icons.qr_code_scanner_rounded, size: 28), + ); + } +} diff --git a/lib/presentation/pages/pin/widgets/pin_sheet.dart b/lib/presentation/pages/pin/widgets/pin_sheet.dart index fa0f133..54d5d82 100644 --- a/lib/presentation/pages/pin/widgets/pin_sheet.dart +++ b/lib/presentation/pages/pin/widgets/pin_sheet.dart @@ -14,14 +14,21 @@ typedef PinSubmitCallback = Future Function(String pin); enum _PinSheetResult { success, failed, forgotPin, pinNotSet } /// Meminta PIN lalu menjalankan [onSubmit]. Error PIN (ยง6.5) ditangani di dalam -/// sheet; failure lain menutup sheet dan ditampilkan oleh halaman pemanggil. +/// sheet; failure lain menutup sheet lalu diteruskan ke [onFailure]. /// Mengembalikan `true` bila [onSubmit] sukses. +/// +/// Tampilkan failure lewat [onFailure], jangan dari listener bloc: flushbar juga +/// berupa route, jadi bila muncul selagi sheet masih terbuka, pop milik sheet +/// justru menutup flushbar dan Navigator gagal assertion. Future showPinSheet( BuildContext context, { required String title, String? subtitle, required PinSubmitCallback onSubmit, + ValueChanged? onFailure, }) async { + WalletFailure? lastFailure; + final result = await showModalBottomSheet<_PinSheetResult>( context: context, isScrollControlled: true, @@ -30,8 +37,11 @@ Future showPinSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (_) => - _PinSheet(title: title, subtitle: subtitle, onSubmit: onSubmit), + builder: (_) => _PinSheet( + title: title, + subtitle: subtitle, + onSubmit: (pin) async => lastFailure = await onSubmit(pin), + ), ); if (!context.mounted) return false; @@ -46,6 +56,8 @@ Future showPinSheet( context.router.push(PinSetupRoute(purpose: PinOtpPurpose.setup)); return false; case _PinSheetResult.failed: + if (lastFailure != null) onFailure?.call(lastFailure!); + return false; case null: return false; } diff --git a/lib/presentation/pages/wallet/exchange_page.dart b/lib/presentation/pages/wallet/exchange_page.dart index 7e682cf..504839e 100644 --- a/lib/presentation/pages/wallet/exchange_page.dart +++ b/lib/presentation/pages/wallet/exchange_page.dart @@ -139,6 +139,7 @@ class _ExchangePageState extends State { final result = await bloc.stream.firstWhere((s) => !s.isSubmitting); return result.failureOption.toNullable(); }, + onFailure: _showFailure, ); if (success && mounted) { @@ -173,11 +174,6 @@ class _ExchangePageState extends State { Widget build(BuildContext context) { return MultiBlocListener( listeners: [ - BlocListener( - listenWhen: (p, c) => p.failureOption != c.failureOption, - listener: (context, state) => - state.failureOption.fold(() {}, _showFailure), - ), BlocListener( listenWhen: (p, c) => p.preview != c.preview && c.preview != null, listener: (context, state) => setState(() => _rate = state.preview), diff --git a/lib/presentation/pages/wallet/payment_code_page.dart b/lib/presentation/pages/wallet/payment_code_page.dart index 5deb3cd..63ae7c8 100644 --- a/lib/presentation/pages/wallet/payment_code_page.dart +++ b/lib/presentation/pages/wallet/payment_code_page.dart @@ -73,18 +73,13 @@ class _PaymentCodePageState extends State { final state = await bloc.stream.firstWhere((s) => !s.isSubmitting); return state.failureOption.toNullable(); }, + onFailure: (f) => AppFlushbar.showError(context, walletFailureMessage(f)), ); } @override Widget build(BuildContext context) { - return BlocConsumer( - listenWhen: (p, c) => p.failureOption != c.failureOption, - listener: (context, state) => state.failureOption.fold(() {}, (f) { - if (!f.isPinError) { - AppFlushbar.showError(context, walletFailureMessage(f)); - } - }), + return BlocBuilder( builder: (context, state) { final code = state.paymentCode; diff --git a/lib/presentation/pages/wallet/transfer_page.dart b/lib/presentation/pages/wallet/transfer_page.dart index a5a4f81..f96482b 100644 --- a/lib/presentation/pages/wallet/transfer_page.dart +++ b/lib/presentation/pages/wallet/transfer_page.dart @@ -130,6 +130,7 @@ class _TransferPageState extends State { final result = await bloc.stream.firstWhere((s) => !s.isSubmitting); return result.failureOption.toNullable(); }, + onFailure: _showFailure, ); if (success && mounted) { @@ -201,11 +202,6 @@ class _TransferPageState extends State { Widget build(BuildContext context) { return MultiBlocListener( listeners: [ - BlocListener( - listenWhen: (p, c) => p.failureOption != c.failureOption, - listener: (context, state) => - state.failureOption.fold(() {}, _showFailure), - ), BlocListener( listenWhen: (p, c) => p.recipient != c.recipient && c.recipient != null, diff --git a/lib/presentation/router/app_router.dart b/lib/presentation/router/app_router.dart index f7c80a5..4360c99 100644 --- a/lib/presentation/router/app_router.dart +++ b/lib/presentation/router/app_router.dart @@ -26,6 +26,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: HomeRoute.page), AutoRoute(page: VoucherRoute.page), AutoRoute(page: OrderRoute.page), + AutoRoute(page: CommunityRoute.page), AutoRoute(page: ProfileRoute.page), ], ), @@ -91,8 +92,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: FerrisWheelRoute.page), AutoRoute(page: MisteryBoxRoute.page), - // Community - AutoRoute(page: CommunityRoute.page), + // Community (halaman utamanya tab di Main) AutoRoute(page: CommunityDetailRoute.page), AutoRoute(page: CommunityPostDetailRoute.page),