lib/shop_page.dartDart
import 'dart:convert';
import 'constants.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'shop_header.dart';
import 'product_card.dart';
import 'login_page.dart';
RouteObserver für Auto-Refresh
import 'core/api/api_endpoints.dart';
import 'core/api/auth_headers.dart';
import 'core/prefs/pref_keys.dart';
import 'core/utils/distance.dart';
class ShopPage extends StatefulWidget {
const ShopPage({super.key});
@override
State<ShopPage> createState() => _ShopPageState();
}
class _ShopPageState extends State<ShopPage> with RouteAware {
Future<List<dynamic>>? _combinedFuture;
int? _selectedCategoryId;
Timer? _ticker;
// Liste der Favoriten-Vendor-IDs
List<int> _favoriteVendorIds = [];
@override
void initState() {
super.initState();
_initFavorites(); // Favoriten beim Start laden
_loadData();
_ticker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) setState(() {});
});
}
RouteAware subscribe (Variante B)
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute) {
routeObserver.subscribe(this, route);
}
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_ticker?.cancel();
super.dispose();
}
Wird aufgerufen, wenn man von einer anderen Seite zurückkommt (Variante B)
@override
void didPopNext() {
_initFavorites();
}
// SERVERSEITIG: Favoriten laden (WCFM following list)
Future<void> _initFavorites() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(PrefKeys.jwtToken);
// Wenn nicht eingeloggt: lokale Favoriten löschen + nichts anzeigen
if (token == null || token.isEmpty) {
await prefs.remove(PrefKeys.favoriteVendorIds);
setState(() {
_favoriteVendorIds = [];
});
return;
}
try {
final url = ApiEndpoints.myFavorites;
final res = await http
.get(
Uri.parse(url),
headers: AuthHeaders.bearer(token),
)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) {
final decoded = json.decode(res.body);
if (decoded is List) {
final ids = decoded.map((e) => int.tryParse(e.toString()) ?? 0).where((v) => v > 0).toList();
setState(() {
_favoriteVendorIds = ids;
});
// optional Cache (aber kommt vom Server)
await prefs.setStringList(PrefKeys.favoriteVendorIds, ids.map((e) => e.toString()).toList());
return;
}
}
// falls was schiefgeht: leer (aber nix anderes anfassen)
setState(() {
_favoriteVendorIds = [];
});
} catch (e) {
debugPrint("Favorites fetch error: $e");
setState(() {
_favoriteVendorIds = [];
});
}
}
Future<List<dynamic>> _loadData() {
final future = Future.wait([
fetchProducts(),
fetchCategories(),
SharedPreferences.getInstance(),
]);
setState(() {
_combinedFuture = future;
});
return future;
}
// --- FAVORITEN LOGIK ---
void _showLoginRequiredFavoriteBubble() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: false,
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppConstants.mainColor.withOpacity(0.10),
shape: BoxShape.circle,
),
child: const Icon(Icons.favorite, color: AppConstants.mainColor, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Favoriten",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppConstants.fontGrey,
),
),
const SizedBox(height: 6),
const Text(
"Bitte logge dich ein, um Händler zu deinen Favoriten hinzuzufügen.",
style: TextStyle(fontSize: 13, color: AppConstants.fontGrey, height: 1.3),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(
foregroundColor: AppConstants.fontGrey,
side: BorderSide(color: Colors.grey.withOpacity(0.35)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text("Später"),
),
),
const SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LoginPage()),
Variante A
},
style: ElevatedButton.styleFrom(
backgroundColor: AppConstants.mainColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
child: const Text("Einloggen", style: TextStyle(color: Colors.white)),
),
),
],
),
],
),
),
],
),
),
),
);
},
);
}
Future<void> _toggleFavorite(int vendorId) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(PrefKeys.jwtToken);
if (token == null || token.isEmpty) {
_showLoginRequiredFavoriteBubble();
return;
}
try {
final url = ApiEndpoints.toggleFavorite(vendorId.toString());
final res = await http
.post(
Uri.parse(url),
headers: AuthHeaders.bearer(token),
)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) {
final decoded = json.decode(res.body);
if (decoded is Map && decoded['favorites'] is List) {
final favs = (decoded['favorites'] as List).map((e) => int.tryParse(e.toString()) ?? 0).where((v) => v > 0).toList();
setState(() {
_favoriteVendorIds = favs;
});
// Cache (kommt vom Server)
await prefs.setStringList(PrefKeys.favoriteVendorIds, favs.map((e) => e.toString()).toList());
return;
}
}
} catch (e) {
debugPrint("Fehler beim serverseitigen Favorisieren: $e");
}
}
// --- HILFSMETHODEN ---
bool _isOutOfStock(dynamic p) =>
p['stock_status'] == 'outofstock' || (p['stock_quantity'] != null && p['stock_quantity'] <= 0);
bool _isExpired(dynamic p) {
try {
final now = DateTime.now();
final weekdaySlugs = {1: 'montags', 2: 'dienstags', 3: 'mittwochs', 4: 'donnerstags', 5: 'freitags', 6: 'samstags', 7: 'sonntags'};
final weekdayPrefix = {1: 'monday', 2: 'tuesday', 3: 'wednesday', 4: 'thursday', 5: 'friday', 6: 'saturday', 7: 'sunday'};
if (p['meta_data'] != null) {
for (var meta in p['meta_data']) {
if (meta['key'] == "abholzeiten_abholzeiten_${weekdaySlugs[now.weekday]}") {
var val = meta['value'];
if (val is Map && val["${weekdayPrefix[now.weekday]}_bis"] != null) {
List<int> eP = val["${weekdayPrefix[now.weekday]}_bis"].toString().split(':').map(int.parse).toList();
final end = DateTime(now.year, now.month, now.day, eP[0], eP[1]);
return now.isAfter(end);
}
}
}
}
} catch (_) {}
return false;
}
DateTime? _getStartTime(dynamic p) {
try {
final now = DateTime.now();
final weekdaySlugs = {1: 'montags', 2: 'dienstags', 3: 'mittwochs', 4: 'donnerstags', 5: 'freitags', 6: 'samstags', 7: 'sonnerstags'};
final weekdayPrefix = {1: 'monday', 2: 'tuesday', 3: 'wednesday', 4: 'thursday', 5: 'friday', 6: 'saturday', 7: 'sunday'};
if (p['meta_data'] != null) {
for (var meta in p['meta_data']) {
if (meta['key'] == "abholzeiten_abholzeiten_${weekdaySlugs[now.weekday]}") {
var val = meta['value'];
if (val is Map && val["${weekdayPrefix[now.weekday]}_von"] != null) {
List<int> sP = val["${weekdayPrefix[now.weekday]}_von"].toString().split(':').map(int.parse).toList();
return DateTime(now.year, now.month, now.day, sP[0], sP[1]);
}
}
}
}
} catch (_) {}
return null;
}
double _getDistance(dynamic p, SharedPreferences prefs) {
double? userLat = prefs.getDouble(PrefKeys.userLat);
double? userLng = prefs.getDouble(PrefKeys.userLng);
var v = p['vendor_info'];
if (v == null || userLat == null || userLng == null) return -1.0;
double? storeLat = double.tryParse(v['store_lat'].toString());
double? storeLng = double.tryParse(v['store_lng'].toString());
if (storeLat == null || storeLng == null) return -1.0;
return Distance.km(userLat, userLng, storeLat, storeLng);
}
Future<List<dynamic>> fetchProducts() async {
final String url = ApiEndpoints.products(status: 'publish', perPage: 100, cacheBuster: true);
final res = await http.get(Uri.parse(url), headers: AuthHeaders.wcBasic());
if (res.statusCode == 200) {
return json.decode(res.body);
}
return [];
}
Future<List<dynamic>> fetchCategories() async {
final res = await http.get(
Uri.parse(ApiEndpoints.productCategories(hideEmpty: true, perPage: 100)),
headers: AuthHeaders.wcBasic(),
);
if (res.statusCode == 200) return json.decode(res.body);
return [];
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppConstants.baseWhite,
appBar: ShopHeader(onLocationChanged: _loadData),
body: RefreshIndicator(
color: AppConstants.mainColor,
onRefresh: () async {
await _loadData();
await _initFavorites(); // nach Refresh auch Favorites neu ziehen
},
child: FutureBuilder<List<dynamic>>(
future: _combinedFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator(color: AppConstants.mainColor));
final List<dynamic> allProducts = snapshot.data![0];
final List<dynamic> categories = snapshot.data![1];
final SharedPreferences prefs = snapshot.data![2];
List<dynamic> hurryUpList = allProducts.where((p) {
int stock = p['stock_quantity'] ?? 0;
return stock > 0 && stock <= 2 && !_isExpired(p);
}).toList();
List<dynamic> recommendedList = allProducts.where((p) => p['featured'] == true).toList();
List<dynamic> endingSoonList = allProducts.where((p) => !_isExpired(p) && !_isOutOfStock(p)).toList();
endingSoonList.sort((a, b) {
DateTime? t1 = _getStartTime(a);
DateTime? t2 = _getStartTime(b);
if (t1 == null) return 1;
if (t2 == null) return -1;
return t1.compareTo(t2);
});
var filtered = _selectedCategoryId == null
? List.from(allProducts)
: allProducts.where((p) => (p['categories'] as List).any((c) => c['id'] == _selectedCategoryId)).toList();
filtered.sort((a, b) {
bool inactiveA = _isOutOfStock(a) || _isExpired(a);
bool inactiveB = _isOutOfStock(b) || _isExpired(b);
if (inactiveA != inactiveB) return inactiveA ? 1 : -1;
double d1 = _getDistance(a, prefs);
double d2 = _getDistance(b, prefs);
if (d1 < 0) return 1;
if (d2 < 0) return -1;
return d1.compareTo(d2);
});
return ListView(
padding: EdgeInsets.zero,
physics: const AlwaysScrollableScrollPhysics(),
children: [
_categorySlider(categories),
if (_selectedCategoryId == null) ...[
if (hurryUpList.isNotEmpty) ...[
_sectionHeader("🔥 Schnell sein!"),
_productSlider(hurryUpList, prefs, showTimer: true),
const SizedBox(height: 15),
],
if (recommendedList.isNotEmpty) ...[
_sectionHeader("Empfehlung"),
_productSlider(recommendedList, prefs, showTimer: true),
const SizedBox(height: 15),
],
if (endingSoonList.isNotEmpty) ...[
_sectionHeader("Bald endend"),
_productSlider(endingSoonList, prefs, showTimer: true),
const SizedBox(height: 15),
],
],
_sectionHeader(_selectedCategoryId == null ? "In deiner Nähe" : "Ergebnisse"),
_productSlider(filtered, prefs, showTimer: true),
const SizedBox(height: 40),
],
);
},
),
),
);
}
Widget _categorySlider(List<dynamic> cats) {
List<dynamic> items = [
{'id': null, 'name': 'Alle', 'image': null},
...cats
];
return SizedBox(
height: 110,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
itemCount: items.length,
itemBuilder: (context, i) {
var c = items[i];
bool sel = _selectedCategoryId == c['id'];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: GestureDetector(
onTap: () => setState(() => _selectedCategoryId = c['id']),
child: Column(children: [
Container(
width: 65,
height: 65,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: sel ? AppConstants.mainColor.withOpacity(0.1) : Colors.grey[100],
border: Border.all(color: sel ? AppConstants.mainColor : Colors.grey.shade200, width: 2),
),
child: ClipOval(
child: c['image'] != null
? Image.network(c['image']['src'], fit: BoxFit.cover)
: Icon(Icons.grid_view_rounded, color: sel ? AppConstants.mainColor : Colors.grey),
),
),
const SizedBox(height: 8),
Text(
c['name'],
style: TextStyle(
fontSize: 11,
color: sel ? AppConstants.mainColor : AppConstants.fontGrey,
fontWeight: sel ? FontWeight.bold : FontWeight.w600,
),
),
]),
),
);
},
),
);
}
Widget _sectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 5, 20, 10),
child: Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppConstants.fontGrey)),
);
}
Widget _productSlider(List<dynamic> items, SharedPreferences prefs, {required bool showTimer}) {
if (items.isEmpty) return const SizedBox(height: 100, child: Center(child: Text("Keine Produkte gefunden", style: TextStyle(color: Colors.grey))));
return SizedBox(
height: 260,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 20),
itemCount: items.length,
itemBuilder: (context, i) {
final p = items[i];
String timerText = "";
bool isOngoing = false;
int vendorId = 0;
try {
vendorId = int.parse(p['vendor_info']?['vendor_id']?.toString() ?? "0");
} catch (_) {}
if (showTimer) {
try {
final now = DateTime.now();
final weekdaySlugs = {1: 'montags', 2: 'dienstags', 3: 'mittwochs', 4: 'donnerstags', 5: 'freitags', 6: 'samstags', 7: 'sonntags'};
final weekdayPrefix = {1: 'monday', 2: 'tuesday', 3: 'wednesday', 4: 'thursday', 5: 'friday', 6: 'saturday', 7: 'sunday'};
String? vonStr, bisStr;
if (p['meta_data'] != null) {
for (var meta in p['meta_data']) {
if (meta['key'] == "abholzeiten_abholzeiten_${weekdaySlugs[now.weekday]}") {
var val = meta['value'];
if (val is Map) {
vonStr = val["${weekdayPrefix[now.weekday]}_von"].toString();
bisStr = val["${weekdayPrefix[now.weekday]}_bis"].toString();
}
}
}
}
if (vonStr != null && bisStr != null && vonStr != "null") {
List<int> sP = vonStr.split(':').map(int.parse).toList();
List<int> eP = bisStr.split(':').map(int.parse).toList();
final start = DateTime(now.year, now.month, now.day, sP[0], sP[1]);
final end = DateTime(now.year, now.month, now.day, eP[0], eP[1]);
if (now.isBefore(start)) {
final diff = start.difference(now);
timerText = "Abholung startet in: ${diff.inHours}h ${diff.inMinutes % 60}m";
} else if (now.isAfter(start) && now.isBefore(end)) {
isOngoing = true;
final diff = end.difference(now);
timerText = "Abholung endet in: ${diff.inHours}h ${diff.inMinutes % 60}m";
}
}
} catch (_) {}
}
return ProductCard(
product: p,
timerText: timerText,
isExpired: _isExpired(p),
isOngoing: isOngoing,
isOutOfStock: _isOutOfStock(p),
distance: _getDistance(p, prefs),
isFavorite: _favoriteVendorIds.contains(vendorId),
onFavoriteToggle: vendorId == 0 ? null : () => _toggleFavorite(vendorId),
);
},
),
);
}
}