Main coves client
at main 313 lines 9.2 kB view raw
1import 'package:cached_network_image/cached_network_image.dart'; 2import 'package:flutter/foundation.dart'; 3import 'package:flutter/material.dart'; 4 5import '../constants/app_colors.dart'; 6import '../models/community.dart'; 7import '../utils/community_handle_utils.dart'; 8import '../utils/display_utils.dart'; 9 10/// Community header widget displaying banner, avatar, and community info 11/// 12/// Layout matches the profile header design pattern: 13/// - Full-width banner image with gradient overlay 14/// - Circular avatar with shadow 15/// - Community name, handle, and description 16/// - Stats row showing subscriber/member counts 17class CommunityHeader extends StatelessWidget { 18 const CommunityHeader({ 19 required this.community, 20 super.key, 21 }); 22 23 final CommunityView? community; 24 25 static const double bannerHeight = 150; 26 27 @override 28 Widget build(BuildContext context) { 29 final isIOS = Theme.of(context).platform == TargetPlatform.iOS; 30 31 return Stack( 32 children: [ 33 // Banner image (or decorative fallback) 34 _buildBannerImage(), 35 // Gradient overlay for text readability 36 Positioned.fill( 37 child: Container( 38 decoration: BoxDecoration( 39 gradient: LinearGradient( 40 begin: Alignment.topCenter, 41 end: Alignment.bottomCenter, 42 colors: [ 43 Colors.transparent, 44 AppColors.background.withValues(alpha: isIOS ? 0.6 : 0.3), 45 AppColors.background, 46 ], 47 stops: isIOS 48 ? const [0.0, 0.25, 0.55] 49 : const [0.0, 0.5, 1.0], 50 ), 51 ), 52 ), 53 ), 54 // Community content 55 SafeArea( 56 bottom: false, 57 child: Padding( 58 padding: const EdgeInsets.only(top: kToolbarHeight), 59 child: UnconstrainedBox( 60 clipBehavior: Clip.hardEdge, 61 alignment: Alignment.topLeft, 62 constrainedAxis: Axis.horizontal, 63 child: Column( 64 crossAxisAlignment: CrossAxisAlignment.start, 65 mainAxisSize: MainAxisSize.min, 66 children: [ 67 // Avatar and name row 68 _buildAvatarAndNameRow(), 69 // Description 70 if (community?.description != null && 71 community!.description!.isNotEmpty) ...[ 72 const SizedBox(height: 4), 73 Padding( 74 padding: const EdgeInsets.symmetric(horizontal: 16), 75 child: Text( 76 community!.description!, 77 style: const TextStyle( 78 fontSize: 14, 79 color: AppColors.textPrimary, 80 height: 1.4, 81 ), 82 maxLines: 2, 83 overflow: TextOverflow.ellipsis, 84 ), 85 ), 86 ], 87 // Stats row 88 const SizedBox(height: 12), 89 Padding( 90 padding: const EdgeInsets.symmetric(horizontal: 16), 91 child: _buildStatsRow(), 92 ), 93 ], 94 ), 95 ), 96 ), 97 ), 98 ], 99 ); 100 } 101 102 Widget _buildBannerImage() { 103 // Communities don't have banners yet, so we use a decorative pattern 104 // that varies based on community name for visual distinction 105 return _buildDefaultBanner(); 106 } 107 108 Widget _buildDefaultBanner() { 109 // Use hash-based color matching the fallback avatar 110 final name = community?.name ?? ''; 111 final baseColor = DisplayUtils.getFallbackColor(name); 112 113 return Container( 114 height: bannerHeight, 115 width: double.infinity, 116 decoration: BoxDecoration( 117 gradient: LinearGradient( 118 begin: Alignment.topLeft, 119 end: Alignment.bottomRight, 120 colors: [ 121 baseColor.withValues(alpha: 0.6), 122 baseColor.withValues(alpha: 0.3), 123 ], 124 ), 125 ), 126 ); 127 } 128 129 Widget _buildAvatarAndNameRow() { 130 const avatarSize = 80.0; 131 132 return Padding( 133 padding: const EdgeInsets.symmetric(horizontal: 16), 134 child: Row( 135 crossAxisAlignment: CrossAxisAlignment.start, 136 children: [ 137 // Circular avatar (matches profile style) 138 Container( 139 width: avatarSize, 140 height: avatarSize, 141 decoration: BoxDecoration( 142 shape: BoxShape.circle, 143 border: Border.all( 144 color: AppColors.background, 145 width: 3, 146 ), 147 boxShadow: [ 148 BoxShadow( 149 color: Colors.black.withValues(alpha: 0.3), 150 blurRadius: 8, 151 offset: const Offset(0, 2), 152 spreadRadius: 1, 153 ), 154 ], 155 ), 156 child: ClipOval( 157 child: _buildAvatar(avatarSize - 6), 158 ), 159 ), 160 const SizedBox(width: 12), 161 // Name and handle column 162 Expanded( 163 child: Column( 164 crossAxisAlignment: CrossAxisAlignment.start, 165 children: [ 166 const SizedBox(height: 4), 167 // Display name 168 Text( 169 community?.displayName ?? community?.name ?? 'Loading...', 170 style: const TextStyle( 171 fontSize: 20, 172 fontWeight: FontWeight.bold, 173 color: AppColors.textPrimary, 174 letterSpacing: -0.3, 175 ), 176 maxLines: 1, 177 overflow: TextOverflow.ellipsis, 178 ), 179 // Handle 180 if (community?.handle != null) ...[ 181 const SizedBox(height: 2), 182 Text( 183 CommunityHandleUtils.formatHandleForDisplay( 184 community!.handle, 185 ) ?? 186 '', 187 style: const TextStyle( 188 fontSize: 14, 189 color: AppColors.teal, 190 fontWeight: FontWeight.w500, 191 ), 192 ), 193 ], 194 ], 195 ), 196 ), 197 ], 198 ), 199 ); 200 } 201 202 Widget _buildAvatar(double size) { 203 if (community?.avatar != null && community!.avatar!.isNotEmpty) { 204 return CachedNetworkImage( 205 imageUrl: community!.avatar!, 206 width: size, 207 height: size, 208 fit: BoxFit.cover, 209 fadeInDuration: Duration.zero, 210 fadeOutDuration: Duration.zero, 211 placeholder: (context, url) => _buildAvatarLoading(size), 212 errorWidget: (context, url, error) { 213 if (kDebugMode) { 214 debugPrint( 215 'Error loading community avatar for ${community?.name}: $error', 216 ); 217 } 218 return _buildFallbackAvatar(size); 219 }, 220 ); 221 } 222 return _buildFallbackAvatar(size); 223 } 224 225 Widget _buildAvatarLoading(double size) { 226 return Container( 227 width: size, 228 height: size, 229 color: AppColors.backgroundSecondary, 230 ); 231 } 232 233 Widget _buildFallbackAvatar(double size) { 234 final name = community?.name ?? ''; 235 final bgColor = DisplayUtils.getFallbackColor(name); 236 237 return Container( 238 width: size, 239 height: size, 240 color: bgColor, 241 child: Center( 242 child: Text( 243 name.isNotEmpty ? name[0].toUpperCase() : 'C', 244 style: TextStyle( 245 fontSize: size * 0.45, 246 fontWeight: FontWeight.bold, 247 color: Colors.white, 248 letterSpacing: -1, 249 ), 250 ), 251 ), 252 ); 253 } 254 255 Widget _buildStatsRow() { 256 return Wrap( 257 spacing: 16, 258 runSpacing: 8, 259 children: [ 260 if (community?.subscriberCount != null) 261 _StatItem( 262 label: 'Subscribers', 263 value: community!.subscriberCount!, 264 ), 265 if (community?.memberCount != null) 266 _StatItem( 267 label: 'Members', 268 value: community!.memberCount!, 269 ), 270 ], 271 ); 272 } 273 274} 275 276/// Stats item showing label and value (matches profile pattern) 277class _StatItem extends StatelessWidget { 278 const _StatItem({ 279 required this.label, 280 required this.value, 281 }); 282 283 final String label; 284 final int value; 285 286 @override 287 Widget build(BuildContext context) { 288 final valueText = DisplayUtils.formatCount(value); 289 290 return RichText( 291 text: TextSpan( 292 children: [ 293 TextSpan( 294 text: valueText, 295 style: const TextStyle( 296 fontSize: 14, 297 fontWeight: FontWeight.bold, 298 color: AppColors.textPrimary, 299 ), 300 ), 301 TextSpan( 302 text: ' $label', 303 style: const TextStyle( 304 fontSize: 14, 305 color: AppColors.textSecondary, 306 ), 307 ), 308 ], 309 ), 310 ); 311 } 312} 313