diff --git a/lib/screens/carousel_detail_screen.dart b/lib/screens/carousel_detail_screen.dart new file mode 100644 index 0000000..41d7663 --- /dev/null +++ b/lib/screens/carousel_detail_screen.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import '../theme/app_theme.dart'; + +/// Detail screen for a tapped carousel slide. +/// Displays the banner image (base64 or network URL), title, and rich HTML detail content. +class CarouselDetailScreen extends StatelessWidget { + final dynamic slide; + const CarouselDetailScreen({super.key, required this.slide}); + + @override + Widget build(BuildContext context) { + final title = slide['name'] as String? ?? 'Slide Details'; + final bodyHtml = slide['body'] as String? ?? ''; + final base64Img = slide['image'] as String?; + final externalUrl = slide['image_url'] as String?; + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Slide image (full width) + _buildSlideImage(base64Img, externalUrl), + + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + + if (bodyHtml.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Html( + data: bodyHtml, + style: { + 'body': Style( + color: AppTheme.onSurface, + fontFamily: 'Manrope', + fontSize: FontSize(15), + lineHeight: const LineHeight(1.6), + ), + 'p': Style(margin: Margins.only(bottom: 12)), + 'h1': Style( + color: AppTheme.onSurface, + fontSize: FontSize(22), + fontWeight: FontWeight.bold, + ), + 'h2': Style( + color: AppTheme.onSurface, + fontSize: FontSize(18), + fontWeight: FontWeight.bold, + ), + 'a': Style(color: AppTheme.secondary), + }, + ), + ) + else + Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'No content available.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildSlideImage(String? base64Img, String? externalUrl) { + if (base64Img != null && base64Img.isNotEmpty) { + try { + final Uint8List bytes = base64Decode(base64Img); + return Image.memory( + bytes, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + ); + } catch (_) { + return _placeholder(); + } + } else if (externalUrl != null && externalUrl.isNotEmpty) { + return Image.network( + externalUrl, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _placeholder(), + loadingBuilder: (ctx, child, progress) { + if (progress == null) return child; + return Container( + height: 220, + color: AppTheme.surfaceContainer, + child: const Center(child: CircularProgressIndicator()), + ); + }, + ); + } else { + return _placeholder(); + } + } + + Widget _placeholder() { + return Container( + height: 220, + color: AppTheme.surfaceContainer, + child: const Center( + child: Icon(Icons.image_rounded, size: 56, color: AppTheme.outlineVariant), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 5da9175..6057b8d 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -23,6 +23,18 @@ class _LoginScreenState extends State { final _passwordController = TextEditingController(); bool _isLoading = false; + @override + void initState() { + super.initState(); + _loadAppConfig(); + } + + Future _loadAppConfig() async { + try { + await OdooService.getAppConfig(); + } catch (_) {} + } + void _login() async { setState(() => _isLoading = true); try { diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 29f012d..0ea4092 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -42,6 +42,13 @@ class _MainShellState extends State { _notificationTimer = Timer.periodic(const Duration(seconds: 30), (_) { _fetchNotificationCount(); }); + _loadAppConfig(); + } + + Future _loadAppConfig() async { + try { + await OdooService.getAppConfig(); + } catch (_) {} } @override @@ -121,84 +128,88 @@ class _MainShellState extends State { Icons.person_rounded, ]; - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: ThemeManager.instance.brandLogo.isNotEmpty - ? Image.memory( - base64Decode(ThemeManager.instance.brandLogo), - height: 36, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => const Text('Mie Mapan'), - ) - : const Text('Mie Mapan'), - actions: [ - Stack( - clipBehavior: Clip.none, - children: [ - IconButton( - icon: const Icon(Icons.notifications_rounded), - tooltip: 'Notifications', - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NotificationsScreen()), - ); - _fetchNotificationCount(); - }, - ), - if (_unreadNotificationCount > 0) - Positioned( - right: 6, - top: 6, - child: Container( - padding: const EdgeInsets.all(3), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - constraints: const BoxConstraints(minWidth: 18, minHeight: 18), - child: Text( - _unreadNotificationCount > 99 - ? '99+' - : '$_unreadNotificationCount', - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + return ListenableBuilder( + listenable: ThemeManager.instance, + builder: (context, _) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar( + title: ThemeManager.instance.brandLogo.isNotEmpty + ? Image.memory( + base64Decode(ThemeManager.instance.brandLogo), + height: 36, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => const Text('Mie Mapan'), + ) + : const Text('Mie Mapan'), + actions: [ + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + icon: const Icon(Icons.notifications_rounded), + tooltip: 'Notifications', + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NotificationsScreen()), + ); + _fetchNotificationCount(); + }, ), - ), + if (_unreadNotificationCount > 0) + Positioned( + right: 6, + top: 6, + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Text( + _unreadNotificationCount > 99 + ? '99+' + : '$_unreadNotificationCount', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + const SizedBox(width: 4), ], ), - const SizedBox(width: 4), - ], - ), - body: IndexedStack( - index: _currentIndex, - children: _pages, - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: (index) { - setState(() => _currentIndex = index); - }, - backgroundColor: AppTheme.surfaceContainerLowest, - indicatorColor: colorScheme.primary, - destinations: List.generate(4, (i) { - return NavigationDestination( - icon: Icon(navIcons[i], - color: i == _currentIndex - ? colorScheme.onSecondaryContainer - : colorScheme.onSurfaceVariant), - selectedIcon: Icon(navIcons[i], color: colorScheme.onSecondaryContainer), - label: navLabels[i], - ); - }), - ), + body: IndexedStack( + index: _currentIndex, + children: _pages, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() => _currentIndex = index); + }, + backgroundColor: AppTheme.surfaceContainerLowest, + indicatorColor: colorScheme.primary, + destinations: List.generate(4, (i) { + return NavigationDestination( + icon: Icon(navIcons[i], + color: i == _currentIndex + ? colorScheme.onSecondaryContainer + : colorScheme.onSurfaceVariant), + selectedIcon: Icon(navIcons[i], color: colorScheme.onSecondaryContainer), + label: navLabels[i], + ); + }), + ), + ); + }, ); } } diff --git a/lib/services/odoo_service.dart b/lib/services/odoo_service.dart index c73be4b..5b94ac4 100644 --- a/lib/services/odoo_service.dart +++ b/lib/services/odoo_service.dart @@ -94,9 +94,10 @@ class OdooService { /// Fetch app configuration (About Us URL, Contact Us URL, Branding & Theme). static Future> getAppConfig() async { - final tempClient = OdooClient(AppConfig.odooUrl); + final activeClient = OdooService().client; + final clientToUse = activeClient ?? OdooClient(AppConfig.odooUrl); try { - final res = await tempClient.callRPC('/api/loyalty/app_config', 'call', {}); + final res = await clientToUse.callRPC('/api/loyalty/app_config', 'call', {}); if (res != null && res['status'] == 'success') { final configMap = { 'about_us_url': (res['about_us_url'] as String?) ?? '', @@ -117,7 +118,9 @@ class OdooService { } catch (_) { return {'about_us_url': '', 'contact_us_url': ''}; } finally { - tempClient.close(); + if (activeClient == null) { + clientToUse.close(); + } } } @@ -230,9 +233,10 @@ class OdooService { /// Fetch public branch information (includes lat/lng for geolocation sorting). static Future> getPublicBranches() async { - final tempClient = OdooClient(AppConfig.odooUrl); + final activeClient = OdooService().client; + final clientToUse = activeClient ?? OdooClient(AppConfig.odooUrl); try { - final res = await tempClient.callRPC( + final res = await clientToUse.callRPC( '/api/loyalty/branches', 'call', {} @@ -245,7 +249,9 @@ class OdooService { } catch (e) { rethrow; } finally { - tempClient.close(); + if (activeClient == null) { + clientToUse.close(); + } } } } diff --git a/lib/widgets/carousel_widget.dart b/lib/widgets/carousel_widget.dart index cc10e40..1160364 100644 --- a/lib/widgets/carousel_widget.dart +++ b/lib/widgets/carousel_widget.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../theme/app_theme.dart'; +import '../screens/carousel_detail_screen.dart'; /// Auto-scrolling carousel widget that shows slides from CMS. /// Each slide can have an uploaded image (base64) or external image URL. @@ -39,14 +39,11 @@ class _CarouselWidgetState extends State { Future.delayed(const Duration(seconds: 4), _autoScroll); } - Future _onTap(dynamic slide) async { - final linkUrl = slide['link_url'] as String?; - if (linkUrl != null && linkUrl.isNotEmpty) { - final uri = Uri.tryParse(linkUrl); - if (uri != null && await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } + void _onTap(dynamic slide) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => CarouselDetailScreen(slide: slide)), + ); } @override