Main coves client
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