feat: add CarouselDetailScreen and implement app configuration fetching during initialization

This commit is contained in:
Suherdy Yacob 2026-06-14 10:51:46 +07:00
parent c32589aba4
commit e73fc63e87
5 changed files with 239 additions and 90 deletions

View File

@ -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),
),
);
}
}

View File

@ -23,6 +23,18 @@ class _LoginScreenState extends State<LoginScreen> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
@override
void initState() {
super.initState();
_loadAppConfig();
}
Future<void> _loadAppConfig() async {
try {
await OdooService.getAppConfig();
} catch (_) {}
}
void _login() async { void _login() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {

View File

@ -42,6 +42,13 @@ class _MainShellState extends State<MainShell> {
_notificationTimer = Timer.periodic(const Duration(seconds: 30), (_) { _notificationTimer = Timer.periodic(const Duration(seconds: 30), (_) {
_fetchNotificationCount(); _fetchNotificationCount();
}); });
_loadAppConfig();
}
Future<void> _loadAppConfig() async {
try {
await OdooService.getAppConfig();
} catch (_) {}
} }
@override @override
@ -121,84 +128,88 @@ class _MainShellState extends State<MainShell> {
Icons.person_rounded, Icons.person_rounded,
]; ];
final colorScheme = Theme.of(context).colorScheme; return ListenableBuilder(
listenable: ThemeManager.instance,
return Scaffold( builder: (context, _) {
appBar: AppBar( final colorScheme = Theme.of(context).colorScheme;
title: ThemeManager.instance.brandLogo.isNotEmpty return Scaffold(
? Image.memory( appBar: AppBar(
base64Decode(ThemeManager.instance.brandLogo), title: ThemeManager.instance.brandLogo.isNotEmpty
height: 36, ? Image.memory(
fit: BoxFit.contain, base64Decode(ThemeManager.instance.brandLogo),
errorBuilder: (context, error, stackTrace) => const Text('Mie Mapan'), height: 36,
) fit: BoxFit.contain,
: const Text('Mie Mapan'), errorBuilder: (context, error, stackTrace) => const Text('Mie Mapan'),
actions: [ )
Stack( : const Text('Mie Mapan'),
clipBehavior: Clip.none, actions: [
children: [ Stack(
IconButton( clipBehavior: Clip.none,
icon: const Icon(Icons.notifications_rounded), children: [
tooltip: 'Notifications', IconButton(
onPressed: () async { icon: const Icon(Icons.notifications_rounded),
await Navigator.push( tooltip: 'Notifications',
context, onPressed: () async {
MaterialPageRoute(builder: (_) => const NotificationsScreen()), await Navigator.push(
); context,
_fetchNotificationCount(); 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,
),
), ),
), 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,
body: IndexedStack( ),
index: _currentIndex, bottomNavigationBar: NavigationBar(
children: _pages, selectedIndex: _currentIndex,
), onDestinationSelected: (index) {
bottomNavigationBar: NavigationBar( setState(() => _currentIndex = index);
selectedIndex: _currentIndex, },
onDestinationSelected: (index) { backgroundColor: AppTheme.surfaceContainerLowest,
setState(() => _currentIndex = index); indicatorColor: colorScheme.primary,
}, destinations: List.generate(4, (i) {
backgroundColor: AppTheme.surfaceContainerLowest, return NavigationDestination(
indicatorColor: colorScheme.primary, icon: Icon(navIcons[i],
destinations: List.generate(4, (i) { color: i == _currentIndex
return NavigationDestination( ? colorScheme.onSecondaryContainer
icon: Icon(navIcons[i], : colorScheme.onSurfaceVariant),
color: i == _currentIndex selectedIcon: Icon(navIcons[i], color: colorScheme.onSecondaryContainer),
? colorScheme.onSecondaryContainer label: navLabels[i],
: colorScheme.onSurfaceVariant), );
selectedIcon: Icon(navIcons[i], color: colorScheme.onSecondaryContainer), }),
label: navLabels[i], ),
); );
}), },
),
); );
} }
} }

View File

@ -94,9 +94,10 @@ class OdooService {
/// Fetch app configuration (About Us URL, Contact Us URL, Branding & Theme). /// Fetch app configuration (About Us URL, Contact Us URL, Branding & Theme).
static Future<Map<String, String>> getAppConfig() async { static Future<Map<String, String>> getAppConfig() async {
final tempClient = OdooClient(AppConfig.odooUrl); final activeClient = OdooService().client;
final clientToUse = activeClient ?? OdooClient(AppConfig.odooUrl);
try { 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') { if (res != null && res['status'] == 'success') {
final configMap = { final configMap = {
'about_us_url': (res['about_us_url'] as String?) ?? '', 'about_us_url': (res['about_us_url'] as String?) ?? '',
@ -117,7 +118,9 @@ class OdooService {
} catch (_) { } catch (_) {
return {'about_us_url': '', 'contact_us_url': ''}; return {'about_us_url': '', 'contact_us_url': ''};
} finally { } finally {
tempClient.close(); if (activeClient == null) {
clientToUse.close();
}
} }
} }
@ -230,9 +233,10 @@ class OdooService {
/// Fetch public branch information (includes lat/lng for geolocation sorting). /// Fetch public branch information (includes lat/lng for geolocation sorting).
static Future<List<dynamic>> getPublicBranches() async { static Future<List<dynamic>> getPublicBranches() async {
final tempClient = OdooClient(AppConfig.odooUrl); final activeClient = OdooService().client;
final clientToUse = activeClient ?? OdooClient(AppConfig.odooUrl);
try { try {
final res = await tempClient.callRPC( final res = await clientToUse.callRPC(
'/api/loyalty/branches', '/api/loyalty/branches',
'call', 'call',
{} {}
@ -245,7 +249,9 @@ class OdooService {
} catch (e) { } catch (e) {
rethrow; rethrow;
} finally { } finally {
tempClient.close(); if (activeClient == null) {
clientToUse.close();
}
} }
} }
} }

View File

@ -2,8 +2,8 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../screens/carousel_detail_screen.dart';
/// Auto-scrolling carousel widget that shows slides from CMS. /// Auto-scrolling carousel widget that shows slides from CMS.
/// Each slide can have an uploaded image (base64) or external image URL. /// Each slide can have an uploaded image (base64) or external image URL.
@ -39,14 +39,11 @@ class _CarouselWidgetState extends State<CarouselWidget> {
Future.delayed(const Duration(seconds: 4), _autoScroll); Future.delayed(const Duration(seconds: 4), _autoScroll);
} }
Future<void> _onTap(dynamic slide) async { void _onTap(dynamic slide) {
final linkUrl = slide['link_url'] as String?; Navigator.push(
if (linkUrl != null && linkUrl.isNotEmpty) { context,
final uri = Uri.tryParse(linkUrl); MaterialPageRoute(builder: (_) => CarouselDetailScreen(slide: slide)),
if (uri != null && await canLaunchUrl(uri)) { );
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
} }
@override @override