161 lines
4.6 KiB
Dart
161 lines
4.6 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
|
|
import '../screens/carousel_detail_screen.dart';
|
|
import '../utils/safe_cast.dart';
|
|
|
|
/// Auto-scrolling carousel widget that shows slides from CMS.
|
|
/// Each slide can have an uploaded image (base64) or external image URL.
|
|
class CarouselWidget extends StatefulWidget {
|
|
final List<dynamic> slides;
|
|
|
|
const CarouselWidget({super.key, required this.slides});
|
|
|
|
@override
|
|
State<CarouselWidget> createState() => _CarouselWidgetState();
|
|
}
|
|
|
|
class _CarouselWidgetState extends State<CarouselWidget> {
|
|
final PageController _controller = PageController();
|
|
int _current = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.slides.length > 1) {
|
|
Future.delayed(const Duration(seconds: 4), _autoScroll);
|
|
}
|
|
}
|
|
|
|
void _autoScroll() {
|
|
if (!mounted) return;
|
|
final next = (_current + 1) % widget.slides.length;
|
|
_controller.animateToPage(
|
|
next,
|
|
duration: const Duration(milliseconds: 500),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
Future.delayed(const Duration(seconds: 4), _autoScroll);
|
|
}
|
|
|
|
void _onTap(dynamic slide) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => CarouselDetailScreen(slide: slide)),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.slides.isEmpty) return const SizedBox.shrink();
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
|
|
return Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 180,
|
|
child: PageView.builder(
|
|
controller: _controller,
|
|
itemCount: widget.slides.length,
|
|
onPageChanged: (i) => setState(() => _current = i),
|
|
itemBuilder: (context, index) {
|
|
final slide = widget.slides[index];
|
|
return GestureDetector(
|
|
onTap: () => _onTap(slide),
|
|
child: _SlideImage(slide: slide),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (widget.slides.length > 1)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: SmoothPageIndicator(
|
|
controller: _controller,
|
|
count: widget.slides.length,
|
|
effect: ExpandingDotsEffect(
|
|
dotHeight: 6,
|
|
dotWidth: 6,
|
|
expansionFactor: 3,
|
|
dotColor: colorScheme.surfaceContainer,
|
|
activeDotColor: colorScheme.secondary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SlideImage extends StatelessWidget {
|
|
final dynamic slide;
|
|
const _SlideImage({required this.slide});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final base64Img = safeString(slide['image']);
|
|
final externalUrl = safeString(slide['image_url']);
|
|
|
|
Widget image;
|
|
|
|
if (base64Img != null && base64Img.isNotEmpty) {
|
|
// Uploaded image — decode base64
|
|
try {
|
|
final Uint8List bytes = base64Decode(base64Img);
|
|
image = Image.memory(bytes, fit: BoxFit.cover, width: double.infinity);
|
|
} catch (_) {
|
|
image = _placeholder(colorScheme);
|
|
}
|
|
} else if (externalUrl != null && externalUrl.isNotEmpty) {
|
|
// External URL image
|
|
image = Image.network(
|
|
externalUrl,
|
|
fit: BoxFit.cover,
|
|
width: double.infinity,
|
|
errorBuilder: (_, _, _) => _placeholder(colorScheme),
|
|
loadingBuilder: (ctx, child, progress) {
|
|
if (progress == null) return child;
|
|
return const Center(child: CircularProgressIndicator());
|
|
},
|
|
);
|
|
} else {
|
|
image = _placeholder(colorScheme);
|
|
}
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(borderRadius: BorderRadius.circular(16), child: image),
|
|
);
|
|
}
|
|
|
|
Widget _placeholder(ColorScheme colorScheme) {
|
|
return Container(
|
|
color: colorScheme.surfaceContainer,
|
|
child: Center(
|
|
child: Icon(Icons.image_rounded, size: 48, color: colorScheme.outline),
|
|
),
|
|
);
|
|
}
|
|
}
|