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();
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadAppConfig();
}
Future<void> _loadAppConfig() async {
try {
await OdooService.getAppConfig();
} catch (_) {}
}
void _login() async {
setState(() => _isLoading = true);
try {

View File

@ -42,6 +42,13 @@ class _MainShellState extends State<MainShell> {
_notificationTimer = Timer.periodic(const Duration(seconds: 30), (_) {
_fetchNotificationCount();
});
_loadAppConfig();
}
Future<void> _loadAppConfig() async {
try {
await OdooService.getAppConfig();
} catch (_) {}
}
@override
@ -121,84 +128,88 @@ class _MainShellState extends State<MainShell> {
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],
);
}),
),
);
},
);
}
}

View File

@ -94,9 +94,10 @@ class OdooService {
/// Fetch app configuration (About Us URL, Contact Us URL, Branding & Theme).
static Future<Map<String, String>> 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<List<dynamic>> 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();
}
}
}
}

View File

@ -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<CarouselWidget> {
Future.delayed(const Duration(seconds: 4), _autoScroll);
}
Future<void> _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