sojorn/sojorn_app/lib/widgets/reactions/reaction_picker.dart
2026-02-01 16:06:12 -06:00

696 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
class ReactionPicker extends StatefulWidget {
final Function(String) onReactionSelected;
final VoidCallback? onClosed;
final List<String>? reactions;
final Map<String, int>? reactionCounts;
final Set<String>? myReactions;
const ReactionPicker({
super.key,
required this.onReactionSelected,
this.onClosed,
this.reactions,
this.reactionCounts,
this.myReactions,
});
@override
State<ReactionPicker> createState() => _ReactionPickerState();
}
class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProviderStateMixin {
late TabController _tabController;
int _currentTabIndex = 0;
final TextEditingController _searchController = TextEditingController();
bool _isSearching = false;
List<String> _filteredReactions = [];
// Dynamic reaction sets
Map<String, List<String>> _reactionSets = {};
Map<String, String> _folderCredits = {};
List<String> _tabOrder = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_searchController.addListener(_onSearchChanged);
_loadReactionSets();
}
Future<void> _loadReactionSets() async {
// Start with emoji set
final reactionSets = <String, List<String>>{
'emoji': [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
],
};
final folderCredits = <String, String>{};
final tabOrder = ['emoji']; // Start with emoji, will add folders after
// Known reaction folders to check
final knownFolders = ['dotto', 'green', 'blue', 'purple'];
for (final folder in knownFolders) {
try {
// Try to load credit file
final creditContent = await _loadCreditFile(folder);
folderCredits[folder] = creditContent;
// Try to load files from this folder
final reactions = await _loadAllFilesFromFolder(folder);
if (reactions.isNotEmpty) {
reactionSets[folder] = reactions;
tabOrder.add(folder);
}
} catch (e) {
// Error loading folder, skip it
}
}
setState(() {
_reactionSets = reactionSets;
_folderCredits = folderCredits;
_tabOrder = tabOrder;
_isLoading = false;
// Create TabController after setting up the data
_tabController = TabController(length: _tabOrder.length, vsync: this);
_tabController.addListener(() {
setState(() {
_currentTabIndex = _tabController.index;
_clearSearch();
});
});
});
}
Future<List<String>> _loadAllFilesFromFolder(String folder) async {
final reactions = <String>[];
// Common file names to try (comprehensive list)
final possibleFiles = [
// Basic reactions
'heart', 'thumbs_up', 'laugh', 'lol', 'wow', 'sad', 'angry', 'mad',
'party', 'fire', 'clap', 'pray', 'hundred', 'thinking', 'ok',
// Face expressions
'smile', 'happy', 'grinning', 'beaming', 'wink', 'kiss', 'love',
'laughing', 'crying', 'tears', 'joy', 'giggle', 'chuckle',
'frown', 'worried', 'scared', 'fear', 'shock', 'surprised',
'confused', 'thinking_face', 'face_palm', 'eyeroll',
// Extended face names
'laughing_face', 'beaming_face', 'face_with_tears', 'grinning_face',
'smiling_face', 'winking_face', 'melting_face', 'upside_down_face',
'rolling_face', 'slightly_smiling_face', 'smiling_face_with_halo',
'smiling_face_with_hearts', 'face_with_monocle', 'nerd_face',
'party_face', 'sunglasses_face', 'disappointed_face', 'worried_face',
'anguished_face', 'fearful_face', 'downcast_face', 'loudly_crying_face',
// Special characters
'skull', 'ghost', 'robot', 'alien', 'monster', 'devil', 'angel',
'poop', 'vomit', 'sick', 'dizzy', 'sleeping', 'zzz',
// Hearts and love
'heart_with_arrow', 'broken_heart', 'sparkling_heart', 'two_hearts',
'revolving_hearts', 'heart_eyes', 'kissing_heart',
// Colored hearts
'green_heart', 'blue_heart', 'purple_heart', 'yellow_heart',
'black_heart', 'white_heart', 'brown_heart', 'orange_heart',
// Actions and objects
'thumbs_down', 'ok_hand', 'peace', 'victory', 'rock_on', 'call_me',
'point_up', 'point_down', 'point_left', 'point_right',
'raised_hand', 'wave', 'clap', 'high_five', 'pray', 'namaste',
// Nature and elements
'fire', 'water', 'earth', 'air', 'lightning', 'storm', 'rainbow',
'sun', 'moon', 'star', 'cloud', 'tree', 'flower', 'leaf',
// Food and drink
'pizza', 'burger', 'taco', 'ice_cream', 'coffee', 'tea', 'beer',
'wine', 'cocktail', 'cake', 'cookie', 'candy', 'chocolate',
// Animals
'dog', 'cat', 'mouse', 'rabbit', 'bear', 'lion', 'tiger', 'elephant',
'monkey', 'bird', 'fish', 'butterfly', 'spider', 'snake',
// Objects and symbols
'bomb', 'knife', 'gun', 'pistol', 'sword', 'shield', 'crown',
'gem', 'diamond', 'money', 'coin', 'dollar', 'gift', 'present',
// Numbers and symbols
'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
'zero', 'plus', 'minus', 'multiply', 'divide', 'equal', 'check', 'x',
// Common variations with underscores
'thumbs_up', 'thumbs_down', 'middle_finger', 'rock_on', 'peace_sign',
'ok_sign', 'victory_sign', 'call_me_hand', 'raised_hand', 'wave_hand',
// Emoji-style names
'face_with_open_mouth', 'face_with_closed_eyes', 'face_with_tears_of_joy',
'grinning_face_with_big_eyes', 'grinning_face_with_smiling_eyes',
'grinning_face_with_sweat', 'grinning_squinting_face', 'hugging_face',
'face_with_head_bandage', 'face_with_thermometer', 'face_with_bandage',
'nauseated_face', 'sneezing_face', 'yawning_face', 'face_with_cowboy_hat',
// More descriptive names
'party_popper', 'confetti_ball', 'balloon', 'ribbon', 'gift_ribbon',
'birthday_cake', 'wedding_cake', 'christmas_tree', 'pumpkin', 'ghost_halloween',
// Simple variations
'like', 'dislike', 'love', 'hate', 'yes', 'no', 'maybe', 'idk',
'cool', 'hot', 'cold', 'warm', 'fresh', 'old', 'new', 'classic',
// Tech and modern
'computer', 'phone', 'camera', 'video', 'music', 'game', 'controller',
'mouse', 'keyboard', 'screen', 'monitor', 'laptop', 'tablet',
// Expressions
'lol', 'lmao', 'rofl', 'omg', 'wtf', 'smh', 'idc', 'ngl', 'fr',
'tbh', 'iykyk', 'rn', 'asap', 'fyi', 'btw', 'imo', 'imho',
];
// Try both PNG and SVG extensions for each possible file name
for (final fileName in possibleFiles) {
for (final extension in ['png', 'svg']) {
final fullPath = 'reactions/$folder/$fileName.$extension';
try {
// Try to load the file to check if it exists
await rootBundle.load(fullPath);
final assetPath = 'assets/$fullPath';
reactions.add(assetPath);
break; // Found this file, don't try other extensions
} catch (e) {
// File doesn't exist, try next extension
}
}
}
return reactions;
}
Future<String> _loadCreditFile(String folder) async {
try {
final creditData = await rootBundle.loadString('reactions/$folder/credit.md');
return creditData;
} catch (e) {
// Return default credit if file not found
return '# $folder Reaction Set\n\nCustom reaction set for Sojorn';
}
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _clearSearch() {
_searchController.clear();
setState(() {
_isSearching = false;
_filteredReactions = [];
});
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
if (query.isEmpty) {
setState(() {
_isSearching = false;
_filteredReactions = [];
});
} else {
setState(() {
_isSearching = true;
_filteredReactions = _filterReactions(query);
});
}
}
List<String> _filterReactions(String query) {
final reactions = _currentReactions;
return reactions.where((reaction) {
// For image reactions, search by filename
if (reaction.startsWith('assets/reactions/')) {
final fileName = reaction.split('/').last.toLowerCase();
return fileName.contains(query);
}
// For emoji, search by description (you could add a mapping)
return reaction.toLowerCase().contains(query);
}).toList();
}
List<String> get _currentReactions {
if (_tabOrder.isEmpty || _currentTabIndex >= _tabOrder.length) {
return [];
}
final currentTab = _tabOrder[_currentTabIndex];
return _reactionSets[currentTab] ?? [];
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
width: 400,
height: 300,
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
),
child: const Center(
child: CircularProgressIndicator(),
),
),
);
}
final reactions = widget.reactions ?? (_isSearching ? _filteredReactions : _currentReactions);
final reactionCounts = widget.reactionCounts ?? {};
final myReactions = widget.myReactions ?? {};
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.2),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with search
Column(
children: [
Row(
children: [
Text(
_isSearching ? 'Search Reactions' : 'Add Reaction',
style: GoogleFonts.inter(
color: AppTheme.navyBlue,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: () {
Navigator.of(context).pop();
widget.onClosed?.call();
},
icon: Icon(
Icons.close,
color: AppTheme.textSecondary,
size: 20,
),
),
],
),
const SizedBox(height: 12),
// Search bar
Container(
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
),
),
child: TextField(
controller: _searchController,
style: GoogleFonts.inter(
color: AppTheme.navyBlue,
fontSize: 14,
),
decoration: InputDecoration(
hintText: 'Search reactions...',
hintStyle: GoogleFonts.inter(
color: AppTheme.textSecondary,
fontSize: 14,
),
prefixIcon: Icon(
Icons.search,
color: AppTheme.textSecondary,
size: 20,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: AppTheme.textSecondary,
size: 18,
),
onPressed: () {
_searchController.clear();
},
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
),
],
),
const SizedBox(height: 16),
// Tabs
Container(
height: 40,
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
onTap: (index) {
setState(() {
_currentTabIndex = index;
_isSearching = false;
_searchController.clear();
_filteredReactions = [];
});
},
indicator: BoxDecoration(
color: AppTheme.brightNavy,
borderRadius: BorderRadius.circular(10),
),
labelColor: AppTheme.textSecondary,
unselectedLabelColor: AppTheme.textSecondary,
labelStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
),
indicatorSize: TabBarIndicatorSize.tab,
tabs: _tabOrder.map((tabName) {
return Tab(
text: tabName.toUpperCase(),
);
}).toList(),
),
),
const SizedBox(height: 16),
// Search results info
if (_isSearching && _filteredReactions.isEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'No reactions found for "${_searchController.text}"',
style: GoogleFonts.inter(
color: AppTheme.textSecondary,
fontSize: 12,
),
textAlign: TextAlign.center,
),
),
// Reaction grid
SizedBox(
height: _isSearching && _filteredReactions.isNotEmpty
? (_filteredReactions.length / 6).ceil() * 60
: 240, // Dynamic height based on search results
child: TabBarView(
controller: _tabController,
children: _tabOrder.map((tabName) {
final reactions = _reactionSets[tabName] ?? [];
final isEmoji = tabName == 'emoji';
final credit = _folderCredits[tabName];
return Column(
children: [
// Reaction grid
Expanded(
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, isEmoji),
),
// Credit section (only for non-emoji tabs)
if (credit != null && credit.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(color: Colors.grey),
const SizedBox(height: 8),
Text(
'Credits:',
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
// Parse and display credit markdown
_buildCreditDisplay(credit),
],
),
),
],
);
}).toList(),
),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildCreditDisplay(String credit) {
final lines = credit.split('\n');
final widgets = <Widget>[];
for (final line in lines) {
if (line.trim().isEmpty) continue;
if (line.startsWith('# ')) {
// Title
widgets.add(Text(
line.substring(2).trim(),
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
));
} else if (line.startsWith('**') && line.endsWith('**')) {
// Bold text
widgets.add(Text(
line.replaceAll('**', '').trim(),
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
));
} else if (line.startsWith('*') && line.endsWith('*')) {
// Italic text
widgets.add(Text(
line.replaceAll('*', '').trim(),
style: GoogleFonts.inter(
fontSize: 10,
fontStyle: FontStyle.italic,
color: AppTheme.textPrimary,
),
));
} else if (line.startsWith('- ')) {
// List item
widgets.add(Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'${line.substring(2).trim()}',
style: GoogleFonts.inter(
fontSize: 10,
color: AppTheme.textPrimary,
),
),
));
} else {
// Regular text
widgets.add(Text(
line.trim(),
style: GoogleFonts.inter(
fontSize: 10,
color: AppTheme.textPrimary,
),
));
}
widgets.add(const SizedBox(height: 2));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
);
}
Widget _buildReactionGrid(
List<String> reactions,
Map<String, int> reactionCounts,
Set<String> myReactions,
bool useImages,
) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: reactions.length,
itemBuilder: (context, index) {
final reaction = reactions[index];
final count = reactionCounts[reaction] ?? 0;
final isSelected = myReactions.contains(reaction);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.of(context).pop();
widget.onReactionSelected(reaction);
},
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: isSelected
? AppTheme.brightNavy.withValues(alpha: 0.2)
: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppTheme.brightNavy
: AppTheme.navyBlue.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
child: Stack(
children: [
Center(
child: useImages
? _buildImageReaction(reaction)
: _buildEmojiReaction(reaction),
),
if (count > 0)
Positioned(
right: 2,
bottom: 2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.brightNavy,
borderRadius: BorderRadius.circular(8),
),
child: Text(
count > 99 ? '99+' : '$count',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
);
},
);
}
Widget _buildEmojiReaction(String emoji) {
return Text(
emoji,
style: const TextStyle(fontSize: 24),
);
}
Widget _buildImageReaction(String imagePath) {
if (imagePath.endsWith('.svg')) {
return SvgPicture.asset(
imagePath,
width: 32,
height: 32,
placeholderBuilder: (context) => Container(
width: 32,
height: 32,
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.textSecondary,
),
),
errorBuilder: (context, error, stackTrace) {
// Fallback to emoji if image not found
return Icon(
Icons.image_not_supported,
size: 24,
color: AppTheme.textSecondary,
);
},
);
}
return Image.asset(
imagePath,
width: 32,
height: 32,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.image_not_supported,
size: 24,
color: AppTheme.textSecondary,
);
},
);
}
}