amoriai/lib/screens/recommendation/recommendation_screen.dart

1989 lines
69 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../core/constants/app_constants.dart';
import '../../models/recommendation_model.dart';
import '../../services/ocr_service.dart';
import '../../services/gemini_service.dart';
import '../../services/firebase_service.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../services/dialog_service.dart';
class RecommendationScreen extends ConsumerStatefulWidget {
final RecommendationModel recommendation;
final OCRResult? ocrResult;
const RecommendationScreen({
super.key,
required this.recommendation,
this.ocrResult,
});
@override
ConsumerState<RecommendationScreen> createState() =>
_RecommendationScreenState();
}
class _RecommendationScreenState extends ConsumerState<RecommendationScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _chatController = TextEditingController();
final List<ChatMessage> _chatMessages = [];
bool _isLoadingChat = false;
String? _selectedMenuName;
// Menu yang sedang dijadikan konteks di chat (Shopee-style)
MenuRecommendation? _chatContextMenu;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
// Restore previously selected menu (if any)
_selectedMenuName = widget.recommendation.selectedMenuName;
// Generate a dynamic greeting based on recommendations
final recommendationsText = widget.recommendation.recommendations
.take(3)
.map((r) => '${r.menuName} (${r.scorePercentage} cocok)')
.join(', ');
_chatMessages.add(
ChatMessage(
text:
'Halo! Berdasarkan menu yang saya analisis dan preferensi Anda, saya merekomendasikan: $recommendationsText.\n\nAda yang bisa saya bantu jelaskan lebih lanjut tentang menu-menu ini? Atau Anda punya kriteria lain?',
isUser: false,
),
);
}
@override
void dispose() {
_tabController.dispose();
_chatController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) return;
final shouldPop = await _showBackConfirmation();
if (shouldPop && mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
child: Scaffold(
backgroundColor: AppTheme.warmWhite,
appBar: AppBar(
title: const Text(
'Rekomendasi Menu',
style: TextStyle(
color: AppTheme.primaryBrown,
fontWeight: FontWeight.bold,
),
),
backgroundColor: AppTheme.warmWhite,
elevation: 0,
iconTheme: const IconThemeData(color: AppTheme.primaryBrown),
actions: [
IconButton(
icon: Icon(
widget.recommendation.isFavorite
? Icons.favorite
: Icons.favorite_border,
color: widget.recommendation.isFavorite
? AppTheme.error
: AppTheme.primaryBrown,
),
onPressed: _toggleFavorite,
),
PopupMenuButton(
icon: const Icon(Icons.more_vert, color: AppTheme.primaryBrown),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'share',
child: Row(
children: [
Icon(Icons.share),
SizedBox(width: 8),
Text('Bagikan'),
],
),
),
const PopupMenuItem(
value: 'save',
child: Row(
children: [
Icon(Icons.bookmark_border),
SizedBox(width: 8),
Text('Simpan'),
],
),
),
],
onSelected: (value) {
if (value == 'share') {
_shareRecommendation();
} else if (value == 'save') {
_saveRecommendation();
}
},
),
],
bottom: TabBar(
controller: _tabController,
labelColor: AppTheme.primaryBrown,
unselectedLabelColor: AppTheme.grey500,
indicatorColor: AppTheme.primaryBrown,
tabs: const [
Tab(text: 'Rekomendasi'),
Tab(text: 'Menu Asli'),
Tab(text: 'Chat AI'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildRecommendationTab(),
_buildOriginalMenuTab(),
_buildChatTab(),
],
),
),
);
}
Widget _buildRecommendationTab() {
final recs = widget.recommendation.recommendations;
final keinginan = widget.recommendation.recommendations.isNotEmpty
? _detectKeinginan(recs)
: 'minuman';
return SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Card
_buildHeaderCard(),
const SizedBox(height: 24),
// Recommendations List
Text(
'Rekomendasi untuk Anda',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryBrown,
),
),
const SizedBox(height: 4),
// Subtitle hint
Text(
keinginan == 'keduanya'
? 'Slot 1 & 2 adalah pasangan menu yang saling melengkapi ☕🍽️'
: keinginan == 'minuman'
? '3 pilihan minuman terbaik untuk Anda 🥤'
: '3 pilihan makanan terbaik untuk Anda 🍽️',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.grey600,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 16),
...List.generate(recs.length > 3 ? 3 : recs.length, (index) {
final rec = recs[index];
final slotLabels = keinginan == 'keduanya'
? ['Paket Utama ⭐', 'Kombinasi Alternatif 🔄', 'Paket Hemat 💰']
: keinginan == 'minuman'
? ['Pilihan Terbaik ⭐', 'Alternatif 🔄', 'Pilihan Hemat 💰']
: ['Pilihan Terbaik ⭐', 'Alternatif 🔄', 'Pilihan Hemat 💰'];
final slotLabel = index < slotLabels.length ? slotLabels[index] : 'Slot ${index + 1}';
return _buildSlottedRecommendationCard(rec, index + 1, slotLabel, keinginan);
}),
],
),
);
}
/// Deteksi jenis keinginan dari distribusi rekomendasi
String _detectKeinginan(List<MenuRecommendation> recs) {
int drinkCount = 0;
int foodCount = 0;
for (final rec in recs) {
final cat = rec.category.toLowerCase();
final name = rec.menuName.toLowerCase();
final isDrink = cat.contains('minuman') || cat.contains('kopi') || cat.contains('teh') ||
cat.contains('drink') || name.contains('kopi') || name.contains('coffee') ||
name.contains('teh') || name.contains('latte') || name.contains('jus') ||
name.contains('milk') || name.contains('matcha') || name.contains('mocha');
if (isDrink) drinkCount++;
else foodCount++;
}
if (drinkCount > 0 && foodCount > 0) return 'keduanya';
if (drinkCount > foodCount) return 'minuman';
return 'makanan';
}
Widget _buildSlottedRecommendationCard(
MenuRecommendation recommendation,
int slotNumber,
String slotLabel,
String keinginan,
) {
// ── Mapping Type langsung dari AI (Lebih Akurat) ──
final String type = recommendation.slotType?.toLowerCase() ?? '';
final bool isMakanan = type.contains('makan');
final bool isMinuman = type.contains('minum');
final bool isBundling = type.contains('bundling');
// Tampilkan badge tipe menu secara eksplisit jika AI memberikannya
final bool showTypeBadge = isMakanan || isMinuman || isBundling;
final isSelectedMenu = _selectedMenuName == recommendation.menuName;
return Column(
children: [
// Slot Header Label
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppTheme.primaryBrown,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$slotNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 8),
Text(
slotLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primaryBrown,
),
),
if (showTypeBadge) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isBundling ? Colors.green.shade50 : (isMakanan ? Colors.orange.shade50 : Colors.blue.shade50),
border: Border.all(color: isBundling ? Colors.green.shade300 : (isMakanan ? Colors.orange.shade300 : Colors.blue.shade300)),
borderRadius: BorderRadius.circular(8),
),
child: Text(
isBundling ? '🍱 Paket Bundling' : (isMakanan ? '🍽️ Makanan' : '☕ Minuman'),
style: TextStyle(
fontSize: 11,
color: isBundling ? Colors.green.shade700 : (isMakanan ? Colors.orange.shade700 : Colors.blue.shade700),
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
// The actual recommendation card
_buildRecommendationCard(recommendation),
],
);
}
Widget _buildHeaderCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppTheme.primaryBrown, AppTheme.coffeeBrown],
),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
boxShadow: [
BoxShadow(
color: AppTheme.primaryBrown.withOpacity(0.3),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.auto_awesome, color: Colors.white, size: 24)
.animate(onPlay: (controller) => controller.repeat())
.shimmer(
duration: 2000.ms,
color: Colors.white.withOpacity(0.5),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Rekomendasi Personal',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${(widget.recommendation.confidence * 100).toInt()}% Match',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
Text(
'Berdasarkan ${widget.recommendation.recommendations.length} menu yang terdeteksi',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.9),
),
),
const SizedBox(height: 8),
Text(
widget.recommendation.formattedDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white.withOpacity(0.7),
),
),
],
),
).animate().scale(duration: 600.ms, curve: Curves.easeOutBack);
}
Widget _buildRecommendationCard(MenuRecommendation recommendation) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _selectedMenuName == recommendation.menuName
? Colors.green.shade400
: AppTheme.primaryBrown.withOpacity(0.08),
width: _selectedMenuName == recommendation.menuName ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: _selectedMenuName == recommendation.menuName
? Colors.green.withOpacity(0.12)
: AppTheme.primaryBrown.withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title Header with gradient accent
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
AppTheme.primaryBrown.withOpacity(0.08),
AppTheme.primaryBrown.withOpacity(0.02),
],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
// Coffee icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryBrown.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_getCategoryIcon(recommendation.category),
color: AppTheme.primaryBrown,
size: 20,
),
),
const SizedBox(width: 12),
// Title & Price
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recommendation.menuName,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryBrown,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
recommendation.formattedPrice,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.coffeeBrown,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Score badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppTheme.primaryBrown, AppTheme.coffeeBrown],
),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star_rounded,
color: Colors.amber,
size: 14,
),
const SizedBox(width: 3),
Text(
recommendation.scorePercentage,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
// Body content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Star rating row
Row(
children: [
...List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.only(right: 2),
child: Icon(
index < recommendation.starRating
? Icons.star_rounded
: Icons.star_outline_rounded,
color: index < recommendation.starRating
? Colors.amber
: AppTheme.grey400,
size: 18,
),
);
}),
const SizedBox(width: 8),
Text(
'Kesesuaian ${recommendation.scorePercentage}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.grey600,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
// Tags
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_buildTag(recommendation.category, AppTheme.primaryBrown),
...recommendation.tags.map(
(tag) => _buildTag(tag, AppTheme.grey600),
),
],
),
// Description
if (recommendation.description.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
recommendation.description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.grey700,
height: 1.5,
),
),
],
const SizedBox(height: 12),
// Personalized reason
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.primaryBrown.withOpacity(0.06),
AppTheme.accent.withOpacity(0.04),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.primaryBrown.withOpacity(0.08),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppTheme.primaryBrown.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.auto_awesome,
size: 14,
color: AppTheme.primaryBrown,
),
),
const SizedBox(width: 6),
Text(
'Mengapa cocok untuk Anda',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: AppTheme.primaryBrown,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 6),
Text(
recommendation.reason,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.grey700,
height: 1.4,
),
),
],
),
),
const SizedBox(height: 14),
// Action Buttons — Pilih Menu + Tanya AI
Row(
children: [
// Pilih Menu
Expanded(
child: ElevatedButton(
onPressed: () => _orderItem(recommendation),
style: ElevatedButton.styleFrom(
backgroundColor:
_selectedMenuName == recommendation.menuName
? Colors.green.shade600
: AppTheme.primaryBrown,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_selectedMenuName == recommendation.menuName
? Icons.check_circle_outline
: Icons.shopping_cart_outlined,
size: 16,
),
const SizedBox(width: 6),
Text(
_selectedMenuName == recommendation.menuName
? 'Dipilih ✓'
: 'Pilih Menu',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
const SizedBox(width: 10),
// Tanya AI tentang menu ini
Expanded(
child: OutlinedButton(
onPressed: () => _askAboutMenu(recommendation),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primaryBrown,
side: BorderSide(
color: AppTheme.primaryBrown.withOpacity(0.6),
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline_rounded, size: 16),
SizedBox(width: 6),
Text(
'Tanya AI',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
],
),
],
),
),
],
),
);
}
IconData _getCategoryIcon(String category) {
final lower = category.toLowerCase();
if (lower.contains('coffee') || lower.contains('kopi')) return Icons.coffee;
if (lower.contains('tea') || lower.contains('teh'))
return Icons.emoji_food_beverage;
if (lower.contains('food') || lower.contains('makanan'))
return Icons.restaurant;
if (lower.contains('drink') || lower.contains('minuman'))
return Icons.local_drink;
if (lower.contains('dessert') || lower.contains('snack')) return Icons.cake;
return Icons.local_cafe;
}
Widget _buildTag(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
/// Bangun section foto menu. Mendukung path lokal (Image.file) dan URL (Image.network).
/// Jika ada menuImagePaths (multi-foto), tampilkan sebagai carousel horizontal.
List<Widget> _buildPhotoSection(String? fallbackUrl) {
final rec = widget.recommendation;
final List<String> paths = [];
if (rec.menuImagePaths != null && rec.menuImagePaths!.isNotEmpty) {
paths.addAll(rec.menuImagePaths!);
} else if (fallbackUrl != null && fallbackUrl.isNotEmpty) {
paths.add(fallbackUrl);
}
if (paths.isEmpty) return [];
final pageController = PageController();
return [
Text(
paths.length > 1 ? 'Foto Menu (${paths.length})' : 'Foto Menu',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF3C2415),
letterSpacing: -0.3,
),
),
const SizedBox(height: 12),
// Hint tap to expand
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 220,
child: PageView.builder(
controller: pageController,
itemCount: paths.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => _openFullscreen(paths, index),
child: _buildSinglePhotoWidget(paths[index]),
);
},
),
),
),
// Expand icon overlay (top-right)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => _openFullscreen(paths, 0),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.fullscreen_rounded,
color: Colors.white,
size: 18,
),
),
),
),
],
),
if (paths.length > 1) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(paths.length, (i) {
return AnimatedBuilder(
animation: pageController,
builder: (context, _) {
final page = pageController.hasClients
? (pageController.page ?? 0).round()
: 0;
final isActive = page == i;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive
? const Color(0xFF8B5E3C)
: const Color(0xFFD4CFC8),
borderRadius: BorderRadius.circular(3),
),
);
},
);
}),
),
],
const SizedBox(height: 24),
];
}
void _openFullscreen(List<String> paths, int initialIndex) {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (_, __, ___) =>
_FullscreenPhotoViewer(paths: paths, initialIndex: initialIndex),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 220),
),
);
}
Widget _buildSinglePhotoWidget(String pathOrUrl) {
// Deteksi apakah ini path lokal atau URL
final isLocalFile = !pathOrUrl.startsWith('http');
final errorWidget = Container(
height: 220,
decoration: const BoxDecoration(color: Color(0xFFF0EBE4)),
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.image_not_supported_outlined,
color: Color(0xFFBDB5AB),
size: 32,
),
SizedBox(height: 6),
Text(
'Gambar tidak tersedia',
style: TextStyle(color: Color(0xFFBDB5AB), fontSize: 12),
),
],
),
),
);
if (isLocalFile) {
final file = File(pathOrUrl);
return file.existsSync()
? Image.file(
file,
width: double.infinity,
height: 220,
fit: BoxFit.cover,
)
: errorWidget;
} else {
return Image.network(
pathOrUrl,
width: double.infinity,
height: 220,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => errorWidget,
);
}
}
Widget _buildOriginalMenuTab() {
final menuItems = widget.recommendation.recommendations;
final menuImageUrl = widget.recommendation.menuImageUrl;
return SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Foto Menu Section ────────────────────────────────────────
..._buildPhotoSection(menuImageUrl),
// Stats Bar
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFAF6F1), Color(0xFFF5EDE3)],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE8DFD4)),
),
child: Row(
children: [
const Icon(
Icons.restaurant_menu_rounded,
color: Color(0xFF8B5E3C),
size: 20,
),
const SizedBox(width: 8),
Text(
'${menuItems.length} menu terdeteksi',
style: const TextStyle(
color: Color(0xFF6B3A2A),
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
const Spacer(),
if (widget.ocrResult != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF6B3A2A).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'OCR ${((widget.ocrResult?.confidence ?? 0) * 100).toStringAsFixed(0)}%',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF6B3A2A),
),
),
),
],
),
),
const SizedBox(height: 20),
// Section Title
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF8B5E3C), Color(0xFFD4A574)],
),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'Daftar Menu',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF3C2415),
letterSpacing: -0.3,
),
),
],
),
const SizedBox(height: 14),
// Menu Items List
if (menuItems.isEmpty)
_buildEmptyMenuState()
else
...menuItems.asMap().entries.map(
(entry) => _buildMenuDetailCard(entry.value, entry.key + 1),
),
const SizedBox(height: 20),
// Raw Text Section (collapsible)
if (widget.recommendation.originalMenuText.isNotEmpty)
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFF0EBE4)),
),
child: Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
leading: const Icon(
Icons.text_snippet_outlined,
color: Color(0xFF9E8E7E),
size: 20,
),
title: const Text(
'Teks Menu Asli',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF6B3A2A),
),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFAF6F1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
widget.recommendation.originalMenuText,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Color(0xFF6B5B4B),
height: 1.6,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildEmptyMenuState() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFF0EBE4)),
),
child: const Column(
children: [
Text('🍽️', style: TextStyle(fontSize: 36)),
SizedBox(height: 12),
Text(
'Tidak ada menu terdeteksi',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF6B3A2A),
fontSize: 15,
),
),
SizedBox(height: 4),
Text(
'Coba scan ulang dengan foto yang lebih jelas',
style: TextStyle(color: Color(0xFF9E8E7E), fontSize: 12),
),
],
),
);
}
Widget _buildMenuDetailCard(MenuRecommendation item, int index) {
final score = (item.score * 100).toInt();
final isSelected = widget.recommendation.selectedMenuName == item.menuName;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isSelected
? const Color(0xFFD4A574).withOpacity(0.4)
: const Color(0xFFF0EBE4),
),
boxShadow: [
BoxShadow(
color: isSelected
? const Color(0xFF8B5E3C).withOpacity(0.08)
: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row with number, name, price
Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isSelected
? [
const Color(0xFF6B3A2A).withOpacity(0.06),
const Color(0xFFD4A574).withOpacity(0.08),
]
: [
const Color(0xFFFAF6F1),
const Color(0xFFFAF6F1).withOpacity(0.5),
],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
),
),
child: Row(
children: [
// Number badge
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF8B5E3C), Color(0xFF6B3A2A)],
),
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
'$index',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
),
const SizedBox(width: 12),
// Name
Expanded(
child: Text(
item.menuName,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
color: Color(0xFF3C2415),
letterSpacing: -0.3,
),
),
),
// Price
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: const Color(0xFF6B3A2A).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.formattedPrice,
style: const TextStyle(
color: Color(0xFF6B3A2A),
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
),
],
),
),
// Details body
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category + Score Row
Row(
children: [
_buildInfoChip(
icon: _getCategoryIcon(item.category),
label: item.category,
color: const Color(0xFF8B5E3C),
),
const SizedBox(width: 8),
_buildInfoChip(
icon: Icons.star_rounded,
label: '$score%',
color: score >= 80
? const Color(0xFFE5A100)
: const Color(0xFF9E8E7E),
),
if (isSelected) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF43A047), Color(0xFF66BB6A)],
),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
color: Colors.white,
size: 12,
),
SizedBox(width: 3),
Text(
'Dipilih',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
],
),
// Description
if (item.description.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
item.description,
style: const TextStyle(
color: Color(0xFF7B6B5B),
fontSize: 13,
height: 1.5,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
// Tags
if (item.tags.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: item.tags
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFF5EDE3),
borderRadius: BorderRadius.circular(6),
),
child: Text(
tag,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF8B7B6B),
fontWeight: FontWeight.w500,
),
),
),
)
.toList(),
),
],
],
),
),
],
),
);
}
Widget _buildInfoChip({
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildChatTab() {
return Column(
children: [
// Chat Messages
Expanded(
child: _chatMessages.isEmpty
? _buildChatPlaceholder()
: ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
itemCount: _chatMessages.length,
itemBuilder: (context, index) {
return _buildChatMessage(_chatMessages[index]);
},
),
),
// Chat Input (includes context banner if _chatContextMenu != null)
_buildChatInput(),
],
);
}
Widget _buildChatPlaceholder() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 64, color: AppTheme.grey400),
const SizedBox(height: 16),
Text(
'Halo! Senang sekali bisa membantu Anda memilih menu di Amori Kofie Kediri.',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppTheme.grey800,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Ajukan pertanyaan tentang menu, alergi, atau preferensi lainnya',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.grey500),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildChatMessage(ChatMessage message) {
final isUser = message.isUser;
return Container(
margin: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) ...[
CircleAvatar(
radius: 16,
backgroundColor: AppTheme.primaryBrown,
child: const Icon(Icons.smart_toy, size: 16, color: Colors.white),
),
const SizedBox(width: 8),
],
Flexible(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isUser ? AppTheme.primaryBrown : Colors.white,
borderRadius: BorderRadius.circular(16).copyWith(
bottomLeft: isUser
? const Radius.circular(16)
: const Radius.circular(4),
bottomRight: isUser
? const Radius.circular(4)
: const Radius.circular(16),
),
border: isUser
? null
: Border.all(color: AppTheme.grey300, width: 1),
boxShadow: isUser
? null
: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isUser ? Colors.white : Colors.black87,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
),
if (isUser) ...[
const SizedBox(width: 8),
CircleAvatar(
radius: 16,
backgroundColor: AppTheme.grey300,
child: const Icon(Icons.person, size: 16, color: Colors.white),
),
],
],
),
).animate().fade().slideY(begin: 0.2, end: 0, curve: Curves.easeOut);
}
Widget _buildChatInput() {
return Container(
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Shopee-style context banner ───────────────────────────────
if (_chatContextMenu != null)
_buildChatContextBanner(_chatContextMenu!),
// ── Input row ─────────────────────────────────────────────────
Container(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: _chatContextMenu == null
? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: [],
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _chatController,
style: const TextStyle(color: Colors.black, fontSize: 16),
decoration: InputDecoration(
hintText: _chatContextMenu != null
? 'Tanya tentang ${_chatContextMenu!.menuName}...'
: 'Tanya tentang menu...',
hintStyle: TextStyle(
color: AppTheme.grey500,
fontSize: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
filled: true,
fillColor: AppTheme.grey50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textCapitalization: TextCapitalization.sentences,
autofocus: _chatContextMenu != null,
),
),
const SizedBox(width: 8),
Container(
decoration: const BoxDecoration(
color: AppTheme.primaryBrown,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: _isLoadingChat ? null : _sendMessage,
icon: _isLoadingChat
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Icon(Icons.send, color: Colors.white),
),
),
],
),
),
],
),
);
}
/// Banner konteks menu — seperti produk yang diklik di Shopee chat
Widget _buildChatContextBanner(MenuRecommendation menu) {
return Container(
margin: const EdgeInsets.fromLTRB(12, 6, 12, 0),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.primaryBrown.withOpacity(0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.primaryBrown.withOpacity(0.2)),
),
child: Row(
children: [
// Ikon kategori
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryBrown.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_getCategoryIcon(menu.category),
color: AppTheme.primaryBrown,
size: 20,
),
),
const SizedBox(width: 10),
// Info menu
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
menu.menuName,
style: const TextStyle(
fontFamily: 'Poppins',
fontWeight: FontWeight.w700,
fontSize: 13,
color: Color(0xFF3C2415),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
children: [
Text(
menu.formattedPrice,
style: TextStyle(
fontFamily: 'Poppins',
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.primaryBrown,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppTheme.primaryBrown.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
menu.scorePercentage,
style: TextStyle(
fontFamily: 'Poppins',
fontSize: 10,
color: AppTheme.primaryBrown,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
// Tombol dismiss
GestureDetector(
onTap: () => setState(() => _chatContextMenu = null),
child: Container(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.close_rounded,
size: 16,
color: AppTheme.primaryBrown.withOpacity(0.6),
),
),
),
],
),
);
}
/// Tanya AI tentang menu tertentu — Shopee-style:
/// Tidak auto-send, cukup set konteks menu dan pindah ke tab Chat AI.
void _askAboutMenu(MenuRecommendation recommendation) {
setState(() => _chatContextMenu = recommendation);
// Pindah ke tab Chat AI (index 2)
_tabController.animateTo(2);
}
Future<void> _sendMessage() async {
final message = _chatController.text.trim();
if (message.isEmpty) return;
// Jika ada konteks menu, sertakan di pesan
final contextNote = _chatContextMenu != null
? ' [Konteks: ${_chatContextMenu!.menuName} ${_chatContextMenu!.formattedPrice}]'
: '';
final fullMessage = message + contextNote;
setState(() {
// Tampilkan pesan user tanpa note konteks
_chatMessages.add(ChatMessage(text: message, isUser: true));
_isLoadingChat = true;
// Hapus konteks setelah dikirim (seperti Shopee)
_chatContextMenu = null;
});
_chatController.clear();
try {
final response = await GeminiService().chatWithAI(
message: fullMessage, // Kirim pesan lengkap dengan konteks menu ke AI
context: widget.recommendation,
conversationHistory: _chatMessages
.where((msg) => !msg.isUser)
.map((msg) => msg.text)
.toList(),
);
setState(() {
_chatMessages.add(ChatMessage(text: response, isUser: false));
});
} catch (e) {
setState(() {
_chatMessages.add(
ChatMessage(
text: 'Maaf, terjadi kesalahan. Silakan coba lagi.',
isUser: false,
),
);
});
} finally {
setState(() => _isLoadingChat = false);
}
}
Future<void> _toggleFavorite() async {
try {
final newFavoriteStatus = !widget.recommendation.isFavorite;
await FirebaseService().toggleRecommendationFavorite(
widget.recommendation.id,
newFavoriteStatus,
);
setState(() {
widget.recommendation.copyWith(isFavorite: newFavoriteStatus);
});
DialogService().showSuccess(
newFavoriteStatus ? 'Ditambahkan ke favorit' : 'Dihapus dari favorit',
);
} catch (e) {
DialogService().showError('Gagal mengupdate favorit: $e');
}
}
void _shareRecommendation() {
// Implement share functionality
DialogService().showInfo('Fitur berbagi akan segera hadir');
}
void _saveRecommendation() {
DialogService().showSuccess('Rekomendasi telah disimpan');
}
void _orderItem(MenuRecommendation recommendation) async {
try {
// Toggle selection: tap again to deselect
final isAlreadySelected = _selectedMenuName == recommendation.menuName;
final newName = isAlreadySelected ? null : recommendation.menuName;
setState(() => _selectedMenuName = newName);
// Update the recommendation model with selected menu
widget.recommendation.selectedMenuName = newName;
// Save to Firebase
await FirebaseService().saveRecommendation(widget.recommendation);
if (mounted) {
if (isAlreadySelected) {
DialogService().showInfo('Pilihan dibatalkan.');
} else {
DialogService().showSuccess(
'${recommendation.menuName} dipilih! Tunjukkan ke kasir untuk memesan.',
);
}
}
} catch (e) {
debugPrint('Error saving selected menu: $e');
if (mounted) {
DialogService().showError('Gagal menyimpan pilihan: $e');
}
}
}
Future<bool> _showBackConfirmation() async {
final bool isSelected = _selectedMenuName != null;
final String title = isSelected ? 'Kembali ke Beranda?' : 'Batalkan Rekomendasi?';
final String content = isSelected
? 'Menu telah dipilih. Rekomendasi ini sudah otomatis tersimpan di riwayat Beranda Anda.'
: 'Anda belum memilih menu rekomendasi satupun. Jika Anda kembali, hasil rekomendasi ini hanya akan tersimpan sebagai draf di Riwayat Scan Menu, namun tidak akan muncul di Riwayat Beranda. Yakin ingin kembali?';
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryBrown),
),
content: Text(
content,
style: const TextStyle(height: 1.4, color: AppTheme.grey800),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal', style: TextStyle(color: AppTheme.grey600)),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? AppTheme.primaryBrown : AppTheme.error,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(isSelected ? 'Ya, Kembali' : 'Ya, Batalkan'),
),
],
),
);
return result ?? false;
}
}
class ChatMessage {
final String text;
final bool isUser;
final DateTime timestamp;
ChatMessage({required this.text, required this.isUser, DateTime? timestamp})
: timestamp = timestamp ?? DateTime.now();
}
/// ─────────────────────────────────────────────────────────────────────────────
/// Fullscreen photo viewer — swipe antar foto, pinch-to-zoom, tap to close
/// ─────────────────────────────────────────────────────────────────────────────
class _FullscreenPhotoViewer extends StatefulWidget {
final List<String> paths;
final int initialIndex;
const _FullscreenPhotoViewer({
required this.paths,
required this.initialIndex,
});
@override
State<_FullscreenPhotoViewer> createState() => _FullscreenPhotoViewerState();
}
class _FullscreenPhotoViewerState extends State<_FullscreenPhotoViewer> {
late PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// ── Foto carousel ─────────────────────────────────────────────
PageView.builder(
controller: _pageController,
itemCount: widget.paths.length,
onPageChanged: (i) => setState(() => _currentIndex = i),
itemBuilder: (ctx, index) {
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Center(
child: InteractiveViewer(
minScale: 0.8,
maxScale: 5.0,
child: _buildPhoto(widget.paths[index]),
),
),
);
},
),
// ── Top bar: close button + counter ──────────────────────────
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Counter badge
if (widget.paths.length > 1)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.55),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_currentIndex + 1} / ${widget.paths.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
fontFamily: 'Poppins',
),
),
)
else
const SizedBox.shrink(),
// Close button
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.55),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close_rounded,
color: Colors.white,
size: 20,
),
),
),
],
),
),
),
),
// ── Dot indicators (bottom) ────────────────────────────────
if (widget.paths.length > 1)
Positioned(
bottom: 32,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.paths.length, (i) {
final active = i == _currentIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: active ? 20 : 7,
height: 7,
decoration: BoxDecoration(
color: active
? Colors.white
: Colors.white.withOpacity(0.4),
borderRadius: BorderRadius.circular(4),
),
);
}),
),
),
],
),
);
}
Widget _buildPhoto(String pathOrUrl) {
final isLocal = !pathOrUrl.startsWith('http');
final error = const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image_outlined, color: Colors.white38, size: 48),
SizedBox(height: 8),
Text(
'Gambar tidak tersedia',
style: TextStyle(color: Colors.white38, fontFamily: 'Poppins'),
),
],
),
);
if (isLocal) {
final file = File(pathOrUrl);
return file.existsSync() ? Image.file(file, fit: BoxFit.contain) : error;
} else {
return Image.network(
pathOrUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => error,
);
}
}
}