import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; const _cdnBase = 'https://reactions.sojorn.net'; /// Parsed reaction package ready for use by [ReactionPicker]. class ReactionPackage { final List tabOrder; final Map> reactionSets; // tabId → list of identifiers (URL or emoji) final Map folderCredits; // tabId → credit markdown const ReactionPackage({ required this.tabOrder, required this.reactionSets, required this.folderCredits, }); } /// Riverpod provider that loads reaction sets once per app session. /// Priority: CDN index.json → local assets → hardcoded emoji. final reactionPackageProvider = FutureProvider((ref) async { // 1. Try CDN try { final response = await http .get(Uri.parse('$_cdnBase/index.json')) .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; final tabsRaw = (data['tabs'] as List? ?? []).whereType>(); final tabOrder = ['emoji']; final reactionSets = >{'emoji': _defaultEmoji}; final folderCredits = {}; for (final tab in tabsRaw) { final id = tab['id'] as String? ?? ''; if (id.isEmpty || id == 'emoji') continue; final credit = tab['credit'] as String?; final files = (tab['reactions'] as List? ?? []).whereType().toList(); final urls = files.map((f) => '$_cdnBase/$id/$f').toList(); tabOrder.add(id); reactionSets[id] = urls; if (credit != null && credit.isNotEmpty) { folderCredits[id] = credit; } } // Only return CDN result if we got actual image tabs (not just emoji) if (tabOrder.length > 1) { return ReactionPackage( tabOrder: tabOrder, reactionSets: reactionSets, folderCredits: folderCredits, ); } } } catch (_) {} // 2. Fallback: local assets try { final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); final assetPaths = manifest.listAssets(); final reactionAssets = assetPaths.where((path) { final lp = path.toLowerCase(); return lp.startsWith('assets/reactions/') && (lp.endsWith('.png') || lp.endsWith('.svg') || lp.endsWith('.webp') || lp.endsWith('.jpg') || lp.endsWith('.jpeg') || lp.endsWith('.gif')); }).toList(); if (reactionAssets.isNotEmpty) { final tabOrder = ['emoji']; final reactionSets = >{'emoji': _defaultEmoji}; final folderCredits = {}; for (final path in reactionAssets) { final parts = path.split('/'); if (parts.length >= 4) { final folder = parts[2]; if (!reactionSets.containsKey(folder)) { tabOrder.add(folder); reactionSets[folder] = []; try { final creditPath = 'assets/reactions/$folder/credit.md'; if (assetPaths.contains(creditPath)) { folderCredits[folder] = await rootBundle.loadString(creditPath); } } catch (_) {} } reactionSets[folder]!.add(path); } } for (final key in reactionSets.keys) { if (key != 'emoji') { reactionSets[key]! .sort((a, b) => a.split('/').last.compareTo(b.split('/').last)); } } return ReactionPackage( tabOrder: tabOrder, reactionSets: reactionSets, folderCredits: folderCredits, ); } } catch (_) {} // 3. Hardcoded emoji fallback return ReactionPackage( tabOrder: ['emoji'], reactionSets: {'emoji': _defaultEmoji}, folderCredits: {}, ); }); const _defaultEmoji = [ '❤️', '👍', '😂', '😮', '😢', '😡', '🎉', '🔥', '👏', '🙏', '💯', '🤔', '😍', '🤣', '😊', '👌', '🙌', '💪', '🎯', '⭐', '✨', '🌟', '💫', '☀️', ];