1989 lines
69 KiB
Dart
1989 lines
69 KiB
Dart
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,
|
||
);
|
||
}
|
||
}
|
||
}
|