feat: add community handle display with Lemmy-style formatting

Integrates backend community handles into PostCard with bidirectional
conversion utilities. Community names shown in light blue/cyan with
grey instance domains (!gaming@coves.social format), matching the
user-friendly display from Lemmy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+249 -9
lib
test
+3
lib/constants/app_colors.dart
···
/// White color for primary text
static const textPrimary = Colors.white;
}
···
/// White color for primary text
static const textPrimary = Colors.white;
+
+
/// Community name color - light blue/cyan
+
static const communityName = Color(0xFF7CB9E8);
}
+8 -1
lib/models/post.dart
···
}
class CommunityRef {
-
CommunityRef({required this.did, required this.name, this.avatar});
factory CommunityRef.fromJson(Map<String, dynamic> json) {
return CommunityRef(
did: json['did'] as String,
name: json['name'] as String,
avatar: json['avatar'] as String?,
);
}
final String did;
final String name;
final String? avatar;
}
···
}
class CommunityRef {
+
CommunityRef({
+
required this.did,
+
required this.name,
+
this.handle,
+
this.avatar,
+
});
factory CommunityRef.fromJson(Map<String, dynamic> json) {
return CommunityRef(
did: json['did'] as String,
name: json['name'] as String,
+
handle: json['handle'] as String?,
avatar: json['avatar'] as String?,
);
}
final String did;
final String name;
+
final String? handle;
final String? avatar;
}
+70
lib/utils/community_handle_utils.dart
···
···
+
/// Utility functions for community handle formatting and resolution.
+
///
+
/// Coves communities use atProto handles in the format:
+
/// - DNS format: `gaming.community.coves.social`
+
/// - Display format: `!gaming@coves.social`
+
class CommunityHandleUtils {
+
/// Converts a DNS-style community handle to display format
+
///
+
/// Transforms `gaming.community.coves.social` → `!gaming@coves.social`
+
/// by removing the `.community.` segment
+
///
+
/// Returns null if the handle is null or doesn't contain `.community.`
+
static String? formatHandleForDisplay(String? handle) {
+
if (handle == null || handle.isEmpty) {
+
return null;
+
}
+
+
// Expected format: name.community.instance.domain
+
// e.g., gaming.community.coves.social
+
final parts = handle.split('.');
+
+
// Must have at least 4 parts: [name, community, instance, domain]
+
if (parts.length < 4 || parts[1] != 'community') {
+
return null;
+
}
+
+
// Extract community name (first part)
+
final communityName = parts[0];
+
+
// Extract instance domain (everything after .community.)
+
final instanceDomain = parts.sublist(2).join('.');
+
+
// Format as !name@instance
+
return '!$communityName@$instanceDomain';
+
}
+
+
/// Converts a display-style community handle to DNS format
+
///
+
/// Transforms `!gaming@coves.social` → `gaming.community.coves.social`
+
/// by inserting `.community.` between the name and instance
+
///
+
/// Returns null if the handle is null or doesn't match expected format
+
static String? formatHandleForDNS(String? displayHandle) {
+
if (displayHandle == null || displayHandle.isEmpty) {
+
return null;
+
}
+
+
// Remove leading ! if present
+
final cleaned =
+
displayHandle.startsWith('!')
+
? displayHandle.substring(1)
+
: displayHandle;
+
+
// Expected format: name@instance.domain
+
if (!cleaned.contains('@')) {
+
return null;
+
}
+
+
final parts = cleaned.split('@');
+
if (parts.length != 2) {
+
return null;
+
}
+
+
final communityName = parts[0];
+
final instanceDomain = parts[1];
+
+
// Format as name.community.instance.domain
+
return '$communityName.community.$instanceDomain';
+
}
+
}
+37 -8
lib/widgets/post_card.dart
···
import '../constants/app_colors.dart';
import '../models/post.dart';
import '../utils/date_time_utils.dart';
import 'external_link_bar.dart';
import 'post_card_actions.dart';
···
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
-
Text(
-
'c/${post.post.community.name}',
-
style: const TextStyle(
-
color: AppColors.textPrimary,
-
fontSize: 14,
-
fontWeight: FontWeight.bold,
-
),
-
),
Text(
'@${post.post.author.handle}',
style: const TextStyle(
···
PostCardActions(post: post),
],
),
),
);
}
···
import '../constants/app_colors.dart';
import '../models/post.dart';
+
import '../utils/community_handle_utils.dart';
import '../utils/date_time_utils.dart';
import 'external_link_bar.dart';
import 'post_card_actions.dart';
···
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+
// Community handle with styled parts
+
_buildCommunityHandle(post.post.community),
+
// Author handle
Text(
'@${post.post.author.handle}',
style: const TextStyle(
···
PostCardActions(post: post),
],
),
+
),
+
);
+
}
+
+
/// Builds the community handle with styled parts (name + instance)
+
Widget _buildCommunityHandle(CommunityRef community) {
+
final displayHandle =
+
CommunityHandleUtils.formatHandleForDisplay(community.handle)!;
+
+
// Split the handle into community name and instance
+
// Format: !gaming@coves.social
+
final atIndex = displayHandle.indexOf('@');
+
final communityPart = displayHandle.substring(0, atIndex);
+
final instancePart = displayHandle.substring(atIndex);
+
+
return Text.rich(
+
TextSpan(
+
children: [
+
TextSpan(
+
text: communityPart,
+
style: const TextStyle(
+
color: AppColors.communityName,
+
fontSize: 14,
+
),
+
),
+
TextSpan(
+
text: instancePart,
+
style: TextStyle(
+
color: AppColors.textSecondary.withValues(alpha: 0.6),
+
fontSize: 14,
+
),
+
),
+
],
),
);
}
+131
test/utils/community_handle_utils_test.dart
···
···
+
import 'package:coves_flutter/utils/community_handle_utils.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
+
void main() {
+
group('CommunityHandleUtils', () {
+
group('formatHandleForDisplay', () {
+
test('converts DNS format to display format', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDisplay(
+
'gaming.community.coves.social',
+
),
+
'!gaming@coves.social',
+
);
+
});
+
+
test('handles multi-part instance domains', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDisplay(
+
'tech.community.test.coves.social',
+
),
+
'!tech@test.coves.social',
+
);
+
});
+
+
test('handles hyphenated community names', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDisplay(
+
'world-news.community.coves.social',
+
),
+
'!world-news@coves.social',
+
);
+
});
+
+
test('returns null for null input', () {
+
expect(CommunityHandleUtils.formatHandleForDisplay(null), null);
+
});
+
+
test('returns null for empty string', () {
+
expect(CommunityHandleUtils.formatHandleForDisplay(''), null);
+
});
+
+
test('returns null for invalid format (missing .community.)', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDisplay('gaming.coves.social'),
+
null,
+
);
+
});
+
+
test('returns null for too few parts', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDisplay('gaming.community'),
+
null,
+
);
+
});
+
+
test('returns null if second part is not "community"', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDisplay(
+
'gaming.other.coves.social',
+
),
+
null,
+
);
+
});
+
});
+
+
group('formatHandleForDNS', () {
+
test('converts display format to DNS format', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDNS('!gaming@coves.social'),
+
'gaming.community.coves.social',
+
);
+
});
+
+
test('handles handles without leading !', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDNS('gaming@coves.social'),
+
'gaming.community.coves.social',
+
);
+
});
+
+
test('handles multi-part instance domains', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDNS('!tech@test.coves.social'),
+
'tech.community.test.coves.social',
+
);
+
});
+
+
test('handles hyphenated community names', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDNS('!world-news@coves.social'),
+
'world-news.community.coves.social',
+
);
+
});
+
+
test('returns null for null input', () {
+
expect(CommunityHandleUtils.formatHandleForDNS(null), null);
+
});
+
+
test('returns null for empty string', () {
+
expect(CommunityHandleUtils.formatHandleForDNS(''), null);
+
});
+
+
test('returns null for invalid format (no @)', () {
+
expect(CommunityHandleUtils.formatHandleForDNS('!gaming'), null);
+
});
+
+
test('returns null for multiple @ symbols', () {
+
expect(
+
CommunityHandleUtils.formatHandleForDNS('!gaming@coves@social'),
+
null,
+
);
+
});
+
});
+
+
group('round-trip conversions', () {
+
test('DNS → display → DNS preserves value', () {
+
const original = 'gaming.community.coves.social';
+
final display = CommunityHandleUtils.formatHandleForDisplay(original);
+
final dnsFormat = CommunityHandleUtils.formatHandleForDNS(display);
+
expect(dnsFormat, original);
+
});
+
+
test('display → DNS → display preserves value', () {
+
const original = '!gaming@coves.social';
+
final dnsFormat = CommunityHandleUtils.formatHandleForDNS(original);
+
final display = CommunityHandleUtils.formatHandleForDisplay(dnsFormat);
+
expect(display, original);
+
});
+
});
+
});
+
}