Compare changes

Choose any two refs to compare.

+1 -3
lib/services/comment_service.dart
···
final cid = data['cid'] as String?;
if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
-
throw ApiException(
-
'Invalid response from server - missing uri or cid',
-
);
+
throw ApiException('Invalid response from server - missing uri or cid');
}
if (kDebugMode) {
+96 -87
test/services/comment_service_test.dart
···
);
});
-
test('should throw ApiException on invalid response (null data)', () async {
-
when(
-
mockDio.post<Map<String, dynamic>>(
-
'/xrpc/social.coves.community.comment.create',
-
data: anyNamed('data'),
-
),
-
).thenAnswer(
-
(_) async => Response(
-
requestOptions: RequestOptions(path: ''),
-
statusCode: 200,
-
data: null,
-
),
-
);
+
test(
+
'should throw ApiException on invalid response (null data)',
+
() async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: null,
+
),
+
);
-
expect(
-
() => commentService.createComment(
-
rootUri: 'at://did:plc:author/post/123',
-
rootCid: 'rootCid',
-
parentUri: 'at://did:plc:author/post/123',
-
parentCid: 'parentCid',
-
content: 'Test comment',
-
),
-
throwsA(
-
isA<ApiException>().having(
-
(e) => e.message,
-
'message',
-
contains('no data'),
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
),
-
),
-
);
-
});
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('no data'),
+
),
+
),
+
);
+
},
+
);
-
test('should throw ApiException on invalid response (missing uri)', () async {
-
when(
-
mockDio.post<Map<String, dynamic>>(
-
'/xrpc/social.coves.community.comment.create',
-
data: anyNamed('data'),
-
),
-
).thenAnswer(
-
(_) async => Response(
-
requestOptions: RequestOptions(path: ''),
-
statusCode: 200,
-
data: {'cid': 'bafy123'},
-
),
-
);
+
test(
+
'should throw ApiException on invalid response (missing uri)',
+
() async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {'cid': 'bafy123'},
+
),
+
);
-
expect(
-
() => commentService.createComment(
-
rootUri: 'at://did:plc:author/post/123',
-
rootCid: 'rootCid',
-
parentUri: 'at://did:plc:author/post/123',
-
parentCid: 'parentCid',
-
content: 'Test comment',
-
),
-
throwsA(
-
isA<ApiException>().having(
-
(e) => e.message,
-
'message',
-
contains('missing uri'),
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
),
-
),
-
);
-
});
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('missing uri'),
+
),
+
),
+
);
+
},
+
);
-
test('should throw ApiException on invalid response (empty uri)', () async {
-
when(
-
mockDio.post<Map<String, dynamic>>(
-
'/xrpc/social.coves.community.comment.create',
-
data: anyNamed('data'),
-
),
-
).thenAnswer(
-
(_) async => Response(
-
requestOptions: RequestOptions(path: ''),
-
statusCode: 200,
-
data: {'uri': '', 'cid': 'bafy123'},
-
),
-
);
+
test(
+
'should throw ApiException on invalid response (empty uri)',
+
() async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {'uri': '', 'cid': 'bafy123'},
+
),
+
);
-
expect(
-
() => commentService.createComment(
-
rootUri: 'at://did:plc:author/post/123',
-
rootCid: 'rootCid',
-
parentUri: 'at://did:plc:author/post/123',
-
parentCid: 'parentCid',
-
content: 'Test comment',
-
),
-
throwsA(
-
isA<ApiException>().having(
-
(e) => e.message,
-
'message',
-
contains('missing uri'),
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
),
-
),
-
);
-
});
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('missing uri'),
+
),
+
),
+
);
+
},
+
);
test('should throw ApiException on server error', () async {
when(
+14
lib/config/environment_config.dart
···
static const String _flavor = String.fromEnvironment('FLUTTER_FLAVOR');
/// Explicit environment override via --dart-define=ENVIRONMENT=local
+
/// Also supports --dart-define=ENV=dev for convenience
static const String _envOverride = String.fromEnvironment('ENVIRONMENT');
+
static const String _envShorthand = String.fromEnvironment('ENV');
/// Get current environment based on build configuration
///
···
}
}
+
// Priority 1b: Shorthand ENV override (dev -> local, prod -> production)
+
if (_envShorthand.isNotEmpty) {
+
switch (_envShorthand) {
+
case 'dev':
+
case 'local':
+
return local;
+
case 'prod':
+
case 'production':
+
return production;
+
}
+
}
+
// Priority 2: Flavor-based environment
switch (_flavor) {
case 'dev':
-1
macos/Flutter/Flutter-Debug.xcconfig
···
-
#include "ephemeral/Flutter-Generated.xcconfig"
-1
macos/Flutter/Flutter-Release.xcconfig
···
-
#include "ephemeral/Flutter-Generated.xcconfig"
+14
lib/constants/threading_colors.dart
···
+
import 'package:flutter/material.dart';
+
+
/// Color palette for comment threading depth indicators
+
///
+
/// These colors cycle through as threads get deeper, providing visual
+
/// distinction between nesting levels. Used by CommentCard and CommentThread.
+
const List<Color> kThreadingColors = [
+
Color(0xFFFF6B6B), // Red
+
Color(0xFF4ECDC4), // Teal
+
Color(0xFFFFE66D), // Yellow
+
Color(0xFF95E1D3), // Mint
+
Color(0xFFC7CEEA), // Purple
+
Color(0xFFFFAA5C), // Orange
+
];
+42
lib/widgets/status_bar_overlay.dart
···
+
import 'package:flutter/material.dart';
+
+
import '../constants/app_colors.dart';
+
+
/// A solid color overlay for the status bar area
+
///
+
/// Prevents content from showing through the transparent status bar when
+
/// scrolling. Use with a Stack widget, positioned at the top.
+
///
+
/// Example:
+
/// ```dart
+
/// Stack(
+
/// children: [
+
/// // Your scrollable content
+
/// CustomScrollView(...),
+
/// // Status bar overlay
+
/// const StatusBarOverlay(),
+
/// ],
+
/// )
+
/// ```
+
class StatusBarOverlay extends StatelessWidget {
+
const StatusBarOverlay({
+
this.color = AppColors.background,
+
super.key,
+
});
+
+
/// The color to fill the status bar area with
+
final Color color;
+
+
@override
+
Widget build(BuildContext context) {
+
final statusBarHeight = MediaQuery.of(context).padding.top;
+
+
return Positioned(
+
top: 0,
+
left: 0,
+
right: 0,
+
height: statusBarHeight,
+
child: Container(color: color),
+
);
+
}
+
}
+267
test/widgets/comment_thread_test.dart
···
+
import 'package:coves_flutter/models/comment.dart';
+
import 'package:coves_flutter/models/post.dart';
+
import 'package:coves_flutter/widgets/comment_thread.dart';
+
import 'package:flutter/material.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:provider/provider.dart';
+
+
import '../test_helpers/mock_providers.dart';
+
+
void main() {
+
late MockAuthProvider mockAuthProvider;
+
late MockVoteProvider mockVoteProvider;
+
+
setUp(() {
+
mockAuthProvider = MockAuthProvider();
+
mockVoteProvider = MockVoteProvider();
+
});
+
+
/// Helper to create a test comment
+
CommentView createComment({
+
required String uri,
+
String content = 'Test comment',
+
String handle = 'test.user',
+
}) {
+
return CommentView(
+
uri: uri,
+
cid: 'cid-$uri',
+
content: content,
+
createdAt: DateTime(2025),
+
indexedAt: DateTime(2025),
+
author: AuthorView(did: 'did:plc:author', handle: handle),
+
post: CommentRef(uri: 'at://did:plc:test/post/123', cid: 'post-cid'),
+
stats: CommentStats(upvotes: 5, downvotes: 1, score: 4),
+
);
+
}
+
+
/// Helper to create a thread with nested replies
+
ThreadViewComment createThread({
+
required String uri,
+
String content = 'Test comment',
+
List<ThreadViewComment>? replies,
+
}) {
+
return ThreadViewComment(
+
comment: createComment(uri: uri, content: content),
+
replies: replies,
+
);
+
}
+
+
Widget createTestWidget(
+
ThreadViewComment thread, {
+
int depth = 0,
+
int maxDepth = 5,
+
void Function(ThreadViewComment)? onCommentTap,
+
void Function(String uri)? onCollapseToggle,
+
void Function(ThreadViewComment, List<ThreadViewComment>)? onContinueThread,
+
Set<String> collapsedComments = const {},
+
List<ThreadViewComment> ancestors = const [],
+
}) {
+
return MultiProvider(
+
providers: [
+
ChangeNotifierProvider<MockAuthProvider>.value(value: mockAuthProvider),
+
ChangeNotifierProvider<MockVoteProvider>.value(value: mockVoteProvider),
+
],
+
child: MaterialApp(
+
home: Scaffold(
+
body: SingleChildScrollView(
+
child: CommentThread(
+
thread: thread,
+
depth: depth,
+
maxDepth: maxDepth,
+
onCommentTap: onCommentTap,
+
onCollapseToggle: onCollapseToggle,
+
onContinueThread: onContinueThread,
+
collapsedComments: collapsedComments,
+
ancestors: ancestors,
+
),
+
),
+
),
+
),
+
);
+
}
+
+
group('CommentThread', () {
+
group('countDescendants', () {
+
test('returns 0 for thread with no replies', () {
+
final thread = createThread(uri: 'comment/1');
+
+
expect(CommentThread.countDescendants(thread), 0);
+
});
+
+
test('returns 0 for thread with empty replies', () {
+
final thread = createThread(uri: 'comment/1', replies: []);
+
+
expect(CommentThread.countDescendants(thread), 0);
+
});
+
+
test('counts direct replies', () {
+
final thread = createThread(
+
uri: 'comment/1',
+
replies: [
+
createThread(uri: 'comment/2'),
+
createThread(uri: 'comment/3'),
+
],
+
);
+
+
expect(CommentThread.countDescendants(thread), 2);
+
});
+
+
test('counts nested replies recursively', () {
+
final thread = createThread(
+
uri: 'comment/1',
+
replies: [
+
createThread(
+
uri: 'comment/2',
+
replies: [
+
createThread(uri: 'comment/3'),
+
createThread(
+
uri: 'comment/4',
+
replies: [
+
createThread(uri: 'comment/5'),
+
],
+
),
+
],
+
),
+
],
+
);
+
+
// 1 direct reply + 2 nested + 1 deeply nested = 4
+
expect(CommentThread.countDescendants(thread), 4);
+
});
+
});
+
+
group(
+
'rendering',
+
skip: 'Provider type compatibility issues - needs mock refactoring',
+
() {
+
testWidgets('renders comment content', (tester) async {
+
final thread = createThread(
+
uri: 'comment/1',
+
content: 'Hello, world!',
+
);
+
+
await tester.pumpWidget(createTestWidget(thread));
+
+
expect(find.text('Hello, world!'), findsOneWidget);
+
});
+
+
testWidgets('renders nested replies when depth < maxDepth',
+
(tester) async {
+
final thread = createThread(
+
uri: 'comment/1',
+
content: 'Parent',
+
replies: [
+
createThread(uri: 'comment/2', content: 'Child 1'),
+
createThread(uri: 'comment/3', content: 'Child 2'),
+
],
+
);
+
+
await tester.pumpWidget(createTestWidget(thread));
+
+
expect(find.text('Parent'), findsOneWidget);
+
expect(find.text('Child 1'), findsOneWidget);
+
expect(find.text('Child 2'), findsOneWidget);
+
});
+
+
testWidgets('shows "Read X more replies" at maxDepth', (tester) async {
+
final thread = createThread(
+
uri: 'comment/1',
+
content: 'At max depth',
+
replies: [
+
createThread(uri: 'comment/2', content: 'Hidden reply'),
+
],
+
);
+
+
await tester.pumpWidget(createTestWidget(thread, depth: 5));
+
+
expect(find.text('At max depth'), findsOneWidget);
+
expect(find.textContaining('Read'), findsOneWidget);
+
expect(find.textContaining('more'), findsOneWidget);
+
// The hidden reply should NOT be rendered
+
expect(find.text('Hidden reply'), findsNothing);
+
});
+
+
testWidgets('does not show "Read more" when depth < maxDepth',
+
(tester) async {
+
final thread = createThread(
+
uri: 'comment/1',
+
replies: [
+
createThread(uri: 'comment/2'),
+
],
+
);
+
+
await tester.pumpWidget(createTestWidget(thread, depth: 3));
+
+
expect(find.textContaining('Read'), findsNothing);
+
});
+
+
testWidgets('calls onContinueThread with correct ancestors',
+
(tester) async {
+
ThreadViewComment? tappedThread;
+
List<ThreadViewComment>? receivedAncestors;
+
+
final thread = createThread(
+
uri: 'comment/1',
+
replies: [
+
createThread(uri: 'comment/2'),
+
],
+
);
+
+
await tester.pumpWidget(createTestWidget(
+
thread,
+
depth: 5,
+
onContinueThread: (t, a) {
+
tappedThread = t;
+
receivedAncestors = a;
+
},
+
));
+
+
// Find and tap the "Read more" link
+
final readMoreFinder = find.textContaining('Read');
+
expect(readMoreFinder, findsOneWidget);
+
+
await tester.tap(readMoreFinder);
+
await tester.pump();
+
+
expect(tappedThread, isNotNull);
+
expect(tappedThread!.comment.uri, 'comment/1');
+
expect(receivedAncestors, isNotNull);
+
// ancestors should NOT include the thread itself
+
expect(receivedAncestors, isEmpty);
+
});
+
+
testWidgets('handles correct reply count pluralization',
+
(tester) async {
+
// Single reply
+
final singleReplyThread = createThread(
+
uri: 'comment/1',
+
replies: [
+
createThread(uri: 'comment/2'),
+
],
+
);
+
+
await tester.pumpWidget(
+
createTestWidget(singleReplyThread, depth: 5),
+
);
+
+
expect(find.text('Read 1 more reply'), findsOneWidget);
+
});
+
+
testWidgets('handles multiple replies pluralization', (tester) async {
+
final multiReplyThread = createThread(
+
uri: 'comment/1',
+
replies: [
+
createThread(uri: 'comment/2'),
+
createThread(uri: 'comment/3'),
+
createThread(uri: 'comment/4'),
+
],
+
);
+
+
await tester.pumpWidget(createTestWidget(multiReplyThread, depth: 5));
+
+
expect(find.text('Read 3 more replies'), findsOneWidget);
+
});
+
},
+
);
+
});
+
}
+217
lib/services/comments_provider_cache.dart
···
+
import 'dart:collection';
+
+
import 'package:flutter/foundation.dart';
+
import '../providers/auth_provider.dart';
+
import '../providers/comments_provider.dart';
+
import '../providers/vote_provider.dart';
+
import 'comment_service.dart';
+
+
/// Comments Provider Cache
+
///
+
/// Manages cached CommentsProvider instances per post URI using LRU eviction.
+
/// Inspired by Thunder app's architecture for instant back navigation.
+
///
+
/// Key features:
+
/// - One CommentsProvider per post URI
+
/// - LRU eviction (default: 15 most recent posts)
+
/// - Sign-out cleanup via AuthProvider listener
+
///
+
/// Usage:
+
/// ```dart
+
/// final cache = context.read<CommentsProviderCache>();
+
/// final provider = cache.getProvider(
+
/// postUri: post.uri,
+
/// postCid: post.cid,
+
/// );
+
/// ```
+
class CommentsProviderCache {
+
CommentsProviderCache({
+
required AuthProvider authProvider,
+
required VoteProvider voteProvider,
+
required CommentService commentService,
+
this.maxSize = 15,
+
}) : _authProvider = authProvider,
+
_voteProvider = voteProvider,
+
_commentService = commentService {
+
_wasAuthenticated = _authProvider.isAuthenticated;
+
_authProvider.addListener(_onAuthChanged);
+
}
+
+
final AuthProvider _authProvider;
+
final VoteProvider _voteProvider;
+
final CommentService _commentService;
+
+
/// Maximum number of providers to cache
+
final int maxSize;
+
+
/// LRU cache - LinkedHashMap maintains insertion order
+
/// Most recently accessed items are at the end
+
final LinkedHashMap<String, CommentsProvider> _cache = LinkedHashMap();
+
+
/// Reference counts for "in-use" providers.
+
///
+
/// Screens that hold onto a provider instance should call [acquireProvider]
+
/// and later [releaseProvider] to prevent LRU eviction from disposing a
+
/// provider that is still mounted in the navigation stack.
+
final Map<String, int> _refCounts = {};
+
+
/// Track auth state for sign-out detection
+
bool _wasAuthenticated = false;
+
+
/// Acquire (get or create) a CommentsProvider for a post.
+
///
+
/// This "pins" the provider to avoid LRU eviction while in use.
+
/// Call [releaseProvider] when the consumer unmounts.
+
///
+
/// If provider exists in cache, moves it to end (LRU touch).
+
/// If cache is full, evicts the oldest *unreferenced* provider before
+
/// creating a new one. If all providers are currently referenced, the cache
+
/// may temporarily exceed [maxSize] to avoid disposing active providers.
+
CommentsProvider acquireProvider({
+
required String postUri,
+
required String postCid,
+
}) {
+
final provider = _getOrCreateProvider(postUri: postUri, postCid: postCid);
+
_refCounts[postUri] = (_refCounts[postUri] ?? 0) + 1;
+
return provider;
+
}
+
+
/// Release a previously acquired provider for a post.
+
///
+
/// Once released, the provider becomes eligible for LRU eviction.
+
void releaseProvider(String postUri) {
+
final current = _refCounts[postUri];
+
if (current == null) {
+
return;
+
}
+
+
if (current <= 1) {
+
_refCounts.remove(postUri);
+
} else {
+
_refCounts[postUri] = current - 1;
+
}
+
+
_evictIfNeeded();
+
}
+
+
/// Legacy name kept for compatibility: prefer [acquireProvider].
+
CommentsProvider getProvider({
+
required String postUri,
+
required String postCid,
+
}) => acquireProvider(postUri: postUri, postCid: postCid);
+
+
CommentsProvider _getOrCreateProvider({
+
required String postUri,
+
required String postCid,
+
}) {
+
// Check if already cached
+
if (_cache.containsKey(postUri)) {
+
// Move to end (most recently used)
+
final provider = _cache.remove(postUri)!;
+
_cache[postUri] = provider;
+
+
if (kDebugMode) {
+
debugPrint('๐Ÿ“ฆ Cache hit: $postUri (${_cache.length}/$maxSize)');
+
}
+
+
return provider;
+
}
+
+
// Evict unreferenced providers if at capacity.
+
if (_cache.length >= maxSize) {
+
_evictIfNeeded(includingOne: true);
+
}
+
+
// Create new provider
+
final provider = CommentsProvider(
+
_authProvider,
+
voteProvider: _voteProvider,
+
commentService: _commentService,
+
postUri: postUri,
+
postCid: postCid,
+
);
+
+
_cache[postUri] = provider;
+
+
if (kDebugMode) {
+
debugPrint('๐Ÿ“ฆ Cache miss: $postUri (${_cache.length}/$maxSize)');
+
if (_cache.length > maxSize) {
+
debugPrint(
+
'๐Ÿ“Œ Cache exceeded maxSize because active providers are pinned',
+
);
+
}
+
}
+
+
return provider;
+
}
+
+
void _evictIfNeeded({bool includingOne = false}) {
+
final targetSize = includingOne ? maxSize - 1 : maxSize;
+
while (_cache.length > targetSize) {
+
String? oldestUnreferencedKey;
+
for (final key in _cache.keys) {
+
if ((_refCounts[key] ?? 0) == 0) {
+
oldestUnreferencedKey = key;
+
break;
+
}
+
}
+
+
if (oldestUnreferencedKey == null) {
+
break;
+
}
+
+
final evicted = _cache.remove(oldestUnreferencedKey);
+
evicted?.dispose();
+
+
if (kDebugMode) {
+
debugPrint('๐Ÿ—‘๏ธ Cache evict: $oldestUnreferencedKey');
+
}
+
}
+
}
+
+
/// Check if provider exists without creating
+
bool hasProvider(String postUri) => _cache.containsKey(postUri);
+
+
/// Get existing provider without creating (for checking state)
+
CommentsProvider? peekProvider(String postUri) => _cache[postUri];
+
+
/// Remove specific provider (e.g., after post deletion)
+
void removeProvider(String postUri) {
+
final provider = _cache.remove(postUri);
+
_refCounts.remove(postUri);
+
provider?.dispose();
+
}
+
+
/// Handle auth state changes - clear all on sign-out
+
void _onAuthChanged() {
+
final isAuthenticated = _authProvider.isAuthenticated;
+
+
// Clear all cached providers on sign-out
+
if (_wasAuthenticated && !isAuthenticated) {
+
if (kDebugMode) {
+
debugPrint('๐Ÿ”’ User signed out - clearing ${_cache.length} cached comment providers');
+
}
+
clearAll();
+
}
+
+
_wasAuthenticated = isAuthenticated;
+
}
+
+
/// Clear all cached providers
+
void clearAll() {
+
for (final provider in _cache.values) {
+
provider.dispose();
+
}
+
_cache.clear();
+
_refCounts.clear();
+
}
+
+
/// Current cache size
+
int get size => _cache.length;
+
+
/// Dispose and cleanup
+
void dispose() {
+
_authProvider.removeListener(_onAuthChanged);
+
clearAll();
+
}
+
}
+19
test/test_helpers/mock_providers.dart
···
import 'package:coves_flutter/providers/vote_provider.dart';
import 'package:flutter/foundation.dart';
+
/// Mock CommentsProvider for testing
+
class MockCommentsProvider extends ChangeNotifier {
+
final String postUri;
+
final String postCid;
+
+
MockCommentsProvider({
+
required this.postUri,
+
required this.postCid,
+
});
+
+
final ValueNotifier<DateTime?> currentTimeNotifier = ValueNotifier(null);
+
+
@override
+
void dispose() {
+
currentTimeNotifier.dispose();
+
super.dispose();
+
}
+
}
+
/// Mock AuthProvider for testing
class MockAuthProvider extends ChangeNotifier {
bool _isAuthenticated = false;
+264
lib/models/community.dart
···
+
// Community data models for Coves
+
//
+
// These models match the backend API structure from:
+
// GET /xrpc/social.coves.community.list
+
// POST /xrpc/social.coves.community.post.create
+
+
/// Response from GET /xrpc/social.coves.community.list
+
class CommunitiesResponse {
+
CommunitiesResponse({required this.communities, this.cursor});
+
+
factory CommunitiesResponse.fromJson(Map<String, dynamic> json) {
+
// Handle null communities array from backend
+
final communitiesData = json['communities'];
+
final List<CommunityView> communitiesList;
+
+
if (communitiesData == null) {
+
// Backend returned null, use empty list
+
communitiesList = [];
+
} else {
+
// Parse community items
+
communitiesList = (communitiesData as List<dynamic>)
+
.map(
+
(item) => CommunityView.fromJson(item as Map<String, dynamic>),
+
)
+
.toList();
+
}
+
+
return CommunitiesResponse(
+
communities: communitiesList,
+
cursor: json['cursor'] as String?,
+
);
+
}
+
+
final List<CommunityView> communities;
+
final String? cursor;
+
}
+
+
/// Full community view data
+
class CommunityView {
+
CommunityView({
+
required this.did,
+
required this.name,
+
this.handle,
+
this.displayName,
+
this.description,
+
this.avatar,
+
this.visibility,
+
this.subscriberCount,
+
this.memberCount,
+
this.postCount,
+
this.viewer,
+
});
+
+
factory CommunityView.fromJson(Map<String, dynamic> json) {
+
return CommunityView(
+
did: json['did'] as String,
+
name: json['name'] as String,
+
handle: json['handle'] as String?,
+
displayName: json['displayName'] as String?,
+
description: json['description'] as String?,
+
avatar: json['avatar'] as String?,
+
visibility: json['visibility'] as String?,
+
subscriberCount: json['subscriberCount'] as int?,
+
memberCount: json['memberCount'] as int?,
+
postCount: json['postCount'] as int?,
+
viewer: json['viewer'] != null
+
? CommunityViewerState.fromJson(
+
json['viewer'] as Map<String, dynamic>,
+
)
+
: null,
+
);
+
}
+
+
/// Community DID (decentralized identifier)
+
final String did;
+
+
/// Community name (unique identifier)
+
final String name;
+
+
/// Community handle
+
final String? handle;
+
+
/// Display name for UI
+
final String? displayName;
+
+
/// Community description
+
final String? description;
+
+
/// Avatar URL
+
final String? avatar;
+
+
/// Visibility setting (e.g., "public", "private")
+
final String? visibility;
+
+
/// Number of subscribers
+
final int? subscriberCount;
+
+
/// Number of members
+
final int? memberCount;
+
+
/// Number of posts
+
final int? postCount;
+
+
/// Current user's relationship with this community
+
final CommunityViewerState? viewer;
+
}
+
+
/// Current user's relationship with a community
+
class CommunityViewerState {
+
CommunityViewerState({this.subscribed, this.member});
+
+
factory CommunityViewerState.fromJson(Map<String, dynamic> json) {
+
return CommunityViewerState(
+
subscribed: json['subscribed'] as bool?,
+
member: json['member'] as bool?,
+
);
+
}
+
+
/// Whether the user is subscribed to this community
+
final bool? subscribed;
+
+
/// Whether the user is a member of this community
+
final bool? member;
+
}
+
+
/// Request body for POST /xrpc/social.coves.community.post.create
+
class CreatePostRequest {
+
CreatePostRequest({
+
required this.community,
+
this.title,
+
this.content,
+
this.embed,
+
this.langs,
+
this.labels,
+
});
+
+
Map<String, dynamic> toJson() {
+
final json = <String, dynamic>{
+
'community': community,
+
};
+
+
if (title != null) {
+
json['title'] = title;
+
}
+
if (content != null) {
+
json['content'] = content;
+
}
+
if (embed != null) {
+
json['embed'] = embed!.toJson();
+
}
+
if (langs != null && langs!.isNotEmpty) {
+
json['langs'] = langs;
+
}
+
if (labels != null) {
+
json['labels'] = labels!.toJson();
+
}
+
+
return json;
+
}
+
+
/// Community DID or handle
+
final String community;
+
+
/// Post title
+
final String? title;
+
+
/// Post content/text
+
final String? content;
+
+
/// External link embed
+
final ExternalEmbedInput? embed;
+
+
/// Language codes (e.g., ["en", "es"])
+
final List<String>? langs;
+
+
/// Self-applied content labels
+
final SelfLabels? labels;
+
}
+
+
/// Response from POST /xrpc/social.coves.community.post.create
+
class CreatePostResponse {
+
const CreatePostResponse({required this.uri, required this.cid});
+
+
factory CreatePostResponse.fromJson(Map<String, dynamic> json) {
+
return CreatePostResponse(
+
uri: json['uri'] as String,
+
cid: json['cid'] as String,
+
);
+
}
+
+
/// AT-URI of the created post
+
final String uri;
+
+
/// Content identifier (CID) of the created post
+
final String cid;
+
}
+
+
/// External link embed input for creating posts
+
class ExternalEmbedInput {
+
const ExternalEmbedInput({
+
required this.uri,
+
this.title,
+
this.description,
+
this.thumb,
+
});
+
+
Map<String, dynamic> toJson() {
+
final json = <String, dynamic>{
+
'uri': uri,
+
};
+
+
if (title != null) {
+
json['title'] = title;
+
}
+
if (description != null) {
+
json['description'] = description;
+
}
+
if (thumb != null) {
+
json['thumb'] = thumb;
+
}
+
+
return json;
+
}
+
+
/// URL of the external link
+
final String uri;
+
+
/// Title of the linked content
+
final String? title;
+
+
/// Description of the linked content
+
final String? description;
+
+
/// Thumbnail URL
+
final String? thumb;
+
}
+
+
/// Self-applied content labels
+
class SelfLabels {
+
const SelfLabels({required this.values});
+
+
Map<String, dynamic> toJson() {
+
return {
+
'values': values.map((label) => label.toJson()).toList(),
+
};
+
}
+
+
/// List of self-applied labels
+
final List<SelfLabel> values;
+
}
+
+
/// Individual self-applied label
+
class SelfLabel {
+
const SelfLabel({required this.val});
+
+
Map<String, dynamic> toJson() {
+
return {
+
'val': val,
+
};
+
}
+
+
/// Label value (e.g., "nsfw", "spoiler")
+
final String val;
+
}
+126
lib/services/coves_api_service.dart
···
import '../config/environment_config.dart';
import '../models/comment.dart';
+
import '../models/community.dart';
import '../models/post.dart';
import 'api_exceptions.dart';
···
}
}
+
/// List communities with optional filtering
+
///
+
/// Fetches a list of communities with pagination support.
+
/// Requires authentication.
+
///
+
/// Parameters:
+
/// - [limit]: Number of communities per page (default: 50, max: 100)
+
/// - [cursor]: Pagination cursor from previous response
+
/// - [sort]: Sort order - 'popular', 'new', or 'alphabetical' (default: 'popular')
+
Future<CommunitiesResponse> listCommunities({
+
int limit = 50,
+
String? cursor,
+
String sort = 'popular',
+
}) async {
+
try {
+
if (kDebugMode) {
+
debugPrint('๐Ÿ“ก Fetching communities: sort=$sort, limit=$limit');
+
}
+
+
final queryParams = <String, dynamic>{
+
'limit': limit,
+
'sort': sort,
+
};
+
+
if (cursor != null) {
+
queryParams['cursor'] = cursor;
+
}
+
+
final response = await _dio.get(
+
'/xrpc/social.coves.community.list',
+
queryParameters: queryParams,
+
);
+
+
if (kDebugMode) {
+
debugPrint(
+
'โœ… Communities fetched: '
+
'${response.data['communities']?.length ?? 0} communities',
+
);
+
}
+
+
return CommunitiesResponse.fromJson(
+
response.data as Map<String, dynamic>,
+
);
+
} on DioException catch (e) {
+
_handleDioException(e, 'communities');
+
} catch (e) {
+
if (kDebugMode) {
+
debugPrint('โŒ Error parsing communities response: $e');
+
}
+
throw ApiException('Failed to parse server response', originalError: e);
+
}
+
}
+
+
/// Create a new post in a community
+
///
+
/// Creates a new post with optional title, content, and embed.
+
/// Requires authentication.
+
///
+
/// Parameters:
+
/// - [community]: Community identifier (required)
+
/// - [title]: Post title (optional)
+
/// - [content]: Post content (optional)
+
/// - [embed]: External embed (link, image, etc.) (optional)
+
/// - [langs]: Language codes for the post (optional)
+
/// - [labels]: Self-applied content labels (optional)
+
Future<CreatePostResponse> createPost({
+
required String community,
+
String? title,
+
String? content,
+
ExternalEmbedInput? embed,
+
List<String>? langs,
+
SelfLabels? labels,
+
}) async {
+
try {
+
if (kDebugMode) {
+
debugPrint('๐Ÿ“ก Creating post in community: $community');
+
}
+
+
// Build request body with only non-null fields
+
final requestBody = <String, dynamic>{
+
'community': community,
+
};
+
+
if (title != null) {
+
requestBody['title'] = title;
+
}
+
+
if (content != null) {
+
requestBody['content'] = content;
+
}
+
+
if (embed != null) {
+
requestBody['embed'] = embed.toJson();
+
}
+
+
if (langs != null && langs.isNotEmpty) {
+
requestBody['langs'] = langs;
+
}
+
+
if (labels != null) {
+
requestBody['labels'] = labels.toJson();
+
}
+
+
final response = await _dio.post(
+
'/xrpc/social.coves.community.post.create',
+
data: requestBody,
+
);
+
+
if (kDebugMode) {
+
debugPrint('โœ… Post created successfully');
+
}
+
+
return CreatePostResponse.fromJson(
+
response.data as Map<String, dynamic>,
+
);
+
} on DioException catch (e) {
+
_handleDioException(e, 'create post');
+
} catch (e) {
+
if (kDebugMode) {
+
debugPrint('โŒ Error creating post: $e');
+
}
+
throw ApiException('Failed to create post', originalError: e);
+
}
+
}
+
/// Handle Dio exceptions with specific error types
///
/// Converts generic DioException into specific typed exceptions
+518
lib/screens/compose/community_picker_screen.dart
···
+
import 'dart:async';
+
+
import 'package:cached_network_image/cached_network_image.dart';
+
import 'package:flutter/material.dart';
+
import 'package:provider/provider.dart';
+
+
import '../../constants/app_colors.dart';
+
import '../../models/community.dart';
+
import '../../providers/auth_provider.dart';
+
import '../../services/api_exceptions.dart';
+
import '../../services/coves_api_service.dart';
+
+
/// Community Picker Screen
+
///
+
/// Full-screen interface for selecting a community when creating a post.
+
///
+
/// Features:
+
/// - Search bar with 300ms debounce for client-side filtering
+
/// - Scroll pagination - loads more communities when near bottom
+
/// - Loading, error, and empty states
+
/// - Returns selected community on tap via Navigator.pop
+
///
+
/// Design:
+
/// - Header: "Post to" with X close button
+
/// - Search bar: "Search for a community" with search icon
+
/// - List of communities showing:
+
/// - Avatar (CircleAvatar with first letter fallback)
+
/// - Community name (bold)
+
/// - Member count + optional description
+
class CommunityPickerScreen extends StatefulWidget {
+
const CommunityPickerScreen({super.key});
+
+
@override
+
State<CommunityPickerScreen> createState() => _CommunityPickerScreenState();
+
}
+
+
class _CommunityPickerScreenState extends State<CommunityPickerScreen> {
+
final TextEditingController _searchController = TextEditingController();
+
final ScrollController _scrollController = ScrollController();
+
+
List<CommunityView> _communities = [];
+
List<CommunityView> _filteredCommunities = [];
+
bool _isLoading = false;
+
bool _isLoadingMore = false;
+
String? _error;
+
String? _cursor;
+
bool _hasMore = true;
+
Timer? _searchDebounce;
+
CovesApiService? _apiService;
+
+
@override
+
void initState() {
+
super.initState();
+
_searchController.addListener(_onSearchChanged);
+
_scrollController.addListener(_onScroll);
+
// Defer API initialization to first frame to access context
+
WidgetsBinding.instance.addPostFrameCallback((_) {
+
_initApiService();
+
_loadCommunities();
+
});
+
}
+
+
void _initApiService() {
+
final authProvider = context.read<AuthProvider>();
+
_apiService = CovesApiService(
+
tokenGetter: authProvider.getAccessToken,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
);
+
}
+
+
@override
+
void dispose() {
+
_searchController.dispose();
+
_scrollController.dispose();
+
_searchDebounce?.cancel();
+
_apiService?.dispose();
+
super.dispose();
+
}
+
+
void _onSearchChanged() {
+
// Cancel previous debounce timer
+
_searchDebounce?.cancel();
+
+
// Start new debounce timer (300ms)
+
_searchDebounce = Timer(const Duration(milliseconds: 300), _filterCommunities);
+
}
+
+
void _filterCommunities() {
+
final query = _searchController.text.trim().toLowerCase();
+
+
if (query.isEmpty) {
+
setState(() {
+
_filteredCommunities = _communities;
+
});
+
return;
+
}
+
+
setState(() {
+
_filteredCommunities = _communities.where((community) {
+
final name = community.name.toLowerCase();
+
final displayName = community.displayName?.toLowerCase() ?? '';
+
final description = community.description?.toLowerCase() ?? '';
+
+
return name.contains(query) ||
+
displayName.contains(query) ||
+
description.contains(query);
+
}).toList();
+
});
+
}
+
+
void _onScroll() {
+
// Load more when near bottom (80% scrolled)
+
if (_scrollController.position.pixels >=
+
_scrollController.position.maxScrollExtent * 0.8) {
+
if (!_isLoadingMore && _hasMore && !_isLoading) {
+
_loadMoreCommunities();
+
}
+
}
+
}
+
+
Future<void> _loadCommunities() async {
+
if (_isLoading || _apiService == null) {
+
return;
+
}
+
+
setState(() {
+
_isLoading = true;
+
_error = null;
+
});
+
+
try {
+
final response = await _apiService!.listCommunities(
+
limit: 50,
+
);
+
+
if (mounted) {
+
setState(() {
+
_communities = response.communities;
+
_filteredCommunities = response.communities;
+
_cursor = response.cursor;
+
_hasMore = response.cursor != null && response.cursor!.isNotEmpty;
+
_isLoading = false;
+
});
+
}
+
} on ApiException catch (e) {
+
if (mounted) {
+
setState(() {
+
_error = e.message;
+
_isLoading = false;
+
});
+
}
+
} on Exception catch (e) {
+
if (mounted) {
+
setState(() {
+
_error = 'Failed to load communities: ${e.toString()}';
+
_isLoading = false;
+
});
+
}
+
}
+
}
+
+
Future<void> _loadMoreCommunities() async {
+
if (_isLoadingMore || !_hasMore || _cursor == null || _apiService == null) {
+
return;
+
}
+
+
setState(() {
+
_isLoadingMore = true;
+
});
+
+
try {
+
final response = await _apiService!.listCommunities(
+
limit: 50,
+
cursor: _cursor,
+
);
+
+
if (mounted) {
+
setState(() {
+
_communities.addAll(response.communities);
+
_cursor = response.cursor;
+
_hasMore = response.cursor != null && response.cursor!.isNotEmpty;
+
_isLoadingMore = false;
+
+
// Re-apply search filter if active
+
_filterCommunities();
+
});
+
}
+
} on ApiException catch (e) {
+
if (mounted) {
+
setState(() {
+
_error = e.message;
+
_isLoadingMore = false;
+
});
+
}
+
} on Exception {
+
if (mounted) {
+
setState(() {
+
_isLoadingMore = false;
+
});
+
}
+
}
+
}
+
+
void _onCommunityTap(CommunityView community) {
+
Navigator.pop(context, community);
+
}
+
+
@override
+
Widget build(BuildContext context) {
+
return Scaffold(
+
backgroundColor: AppColors.background,
+
appBar: AppBar(
+
backgroundColor: AppColors.background,
+
foregroundColor: Colors.white,
+
title: const Text('Post to'),
+
elevation: 0,
+
leading: IconButton(
+
icon: const Icon(Icons.close),
+
onPressed: () => Navigator.pop(context),
+
),
+
),
+
body: SafeArea(
+
child: Column(
+
children: [
+
// Search bar
+
Padding(
+
padding: const EdgeInsets.all(16),
+
child: TextField(
+
controller: _searchController,
+
style: const TextStyle(color: Colors.white),
+
decoration: InputDecoration(
+
hintText: 'Search for a community',
+
hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
+
filled: true,
+
fillColor: const Color(0xFF1A2028),
+
border: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: BorderSide.none,
+
),
+
enabledBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: BorderSide.none,
+
),
+
focusedBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(
+
color: AppColors.primary,
+
width: 2,
+
),
+
),
+
prefixIcon: const Icon(
+
Icons.search,
+
color: Color(0xFF5A6B7F),
+
),
+
contentPadding: const EdgeInsets.symmetric(
+
horizontal: 16,
+
vertical: 12,
+
),
+
),
+
),
+
),
+
+
// Community list
+
Expanded(
+
child: _buildBody(),
+
),
+
],
+
),
+
),
+
);
+
}
+
+
Widget _buildBody() {
+
// Loading state (initial load)
+
if (_isLoading) {
+
return const Center(
+
child: CircularProgressIndicator(
+
color: AppColors.primary,
+
),
+
);
+
}
+
+
// Error state
+
if (_error != null) {
+
return Center(
+
child: Padding(
+
padding: const EdgeInsets.all(24),
+
child: Column(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
const Icon(
+
Icons.error_outline,
+
size: 48,
+
color: Color(0xFF5A6B7F),
+
),
+
const SizedBox(height: 16),
+
Text(
+
_error!,
+
style: const TextStyle(
+
color: Color(0xFFB6C2D2),
+
fontSize: 16,
+
),
+
textAlign: TextAlign.center,
+
),
+
const SizedBox(height: 24),
+
ElevatedButton(
+
onPressed: _loadCommunities,
+
style: ElevatedButton.styleFrom(
+
backgroundColor: AppColors.primary,
+
foregroundColor: Colors.white,
+
padding: const EdgeInsets.symmetric(
+
horizontal: 24,
+
vertical: 12,
+
),
+
shape: RoundedRectangleBorder(
+
borderRadius: BorderRadius.circular(8),
+
),
+
),
+
child: const Text('Retry'),
+
),
+
],
+
),
+
),
+
);
+
}
+
+
// Empty state
+
if (_filteredCommunities.isEmpty) {
+
return Center(
+
child: Padding(
+
padding: const EdgeInsets.all(24),
+
child: Column(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
const Icon(
+
Icons.search_off,
+
size: 48,
+
color: Color(0xFF5A6B7F),
+
),
+
const SizedBox(height: 16),
+
Text(
+
_searchController.text.trim().isEmpty
+
? 'No communities found'
+
: 'No communities match your search',
+
style: const TextStyle(
+
color: Color(0xFFB6C2D2),
+
fontSize: 16,
+
),
+
textAlign: TextAlign.center,
+
),
+
],
+
),
+
),
+
);
+
}
+
+
// Community list
+
return ListView.builder(
+
controller: _scrollController,
+
itemCount: _filteredCommunities.length + (_isLoadingMore ? 1 : 0),
+
itemBuilder: (context, index) {
+
// Loading indicator at bottom
+
if (index == _filteredCommunities.length) {
+
return const Padding(
+
padding: EdgeInsets.all(16),
+
child: Center(
+
child: CircularProgressIndicator(
+
color: AppColors.primary,
+
),
+
),
+
);
+
}
+
+
final community = _filteredCommunities[index];
+
return _buildCommunityTile(community);
+
},
+
);
+
}
+
+
Widget _buildCommunityAvatar(CommunityView community) {
+
final fallbackChild = CircleAvatar(
+
radius: 20,
+
backgroundColor: AppColors.backgroundSecondary,
+
foregroundColor: Colors.white,
+
child: Text(
+
community.name.isNotEmpty ? community.name[0].toUpperCase() : '?',
+
style: const TextStyle(
+
fontSize: 16,
+
fontWeight: FontWeight.bold,
+
),
+
),
+
);
+
+
if (community.avatar == null) {
+
return fallbackChild;
+
}
+
+
return CachedNetworkImage(
+
imageUrl: community.avatar!,
+
imageBuilder: (context, imageProvider) => CircleAvatar(
+
radius: 20,
+
backgroundColor: AppColors.backgroundSecondary,
+
backgroundImage: imageProvider,
+
),
+
placeholder: (context, url) => CircleAvatar(
+
radius: 20,
+
backgroundColor: AppColors.backgroundSecondary,
+
child: const SizedBox(
+
width: 16,
+
height: 16,
+
child: CircularProgressIndicator(
+
strokeWidth: 2,
+
color: AppColors.primary,
+
),
+
),
+
),
+
errorWidget: (context, url, error) => fallbackChild,
+
);
+
}
+
+
Widget _buildCommunityTile(CommunityView community) {
+
// Format member count
+
String formatCount(int? count) {
+
if (count == null) {
+
return '0';
+
}
+
if (count >= 1000000) {
+
return '${(count / 1000000).toStringAsFixed(1)}M';
+
} else if (count >= 1000) {
+
return '${(count / 1000).toStringAsFixed(1)}K';
+
}
+
return count.toString();
+
}
+
+
final memberCount = formatCount(community.memberCount);
+
final subscriberCount = formatCount(community.subscriberCount);
+
+
// Build description line
+
var descriptionLine = '';
+
if (community.memberCount != null && community.memberCount! > 0) {
+
descriptionLine = '$memberCount members';
+
if (community.subscriberCount != null &&
+
community.subscriberCount! > 0) {
+
descriptionLine += ' ยท $subscriberCount subscribers';
+
}
+
} else if (community.subscriberCount != null &&
+
community.subscriberCount! > 0) {
+
descriptionLine = '$subscriberCount subscribers';
+
}
+
+
if (community.description != null && community.description!.isNotEmpty) {
+
if (descriptionLine.isNotEmpty) {
+
descriptionLine += ' ยท ';
+
}
+
descriptionLine += community.description!;
+
}
+
+
return Material(
+
color: Colors.transparent,
+
child: InkWell(
+
onTap: () => _onCommunityTap(community),
+
child: Container(
+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+
decoration: const BoxDecoration(
+
border: Border(
+
bottom: BorderSide(
+
color: Color(0xFF2A3441),
+
width: 1,
+
),
+
),
+
),
+
child: Row(
+
children: [
+
// Avatar
+
_buildCommunityAvatar(community),
+
const SizedBox(width: 12),
+
+
// Community info
+
Expanded(
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Community name
+
Text(
+
community.displayName ?? community.name,
+
style: const TextStyle(
+
color: Colors.white,
+
fontSize: 16,
+
fontWeight: FontWeight.bold,
+
),
+
maxLines: 1,
+
overflow: TextOverflow.ellipsis,
+
),
+
+
// Description line
+
if (descriptionLine.isNotEmpty) ...[
+
const SizedBox(height: 4),
+
Text(
+
descriptionLine,
+
style: const TextStyle(
+
color: Color(0xFFB6C2D2),
+
fontSize: 14,
+
),
+
maxLines: 2,
+
overflow: TextOverflow.ellipsis,
+
),
+
],
+
],
+
),
+
),
+
],
+
),
+
),
+
),
+
);
+
}
+
}
+686 -28
lib/screens/home/create_post_screen.dart
···
import 'package:flutter/material.dart';
+
import 'package:provider/provider.dart';
import '../../constants/app_colors.dart';
+
import '../../models/community.dart';
+
import '../../models/post.dart';
+
import '../../providers/auth_provider.dart';
+
import '../../services/api_exceptions.dart';
+
import '../../services/coves_api_service.dart';
+
import '../compose/community_picker_screen.dart';
+
import 'post_detail_screen.dart';
-
class CreatePostScreen extends StatelessWidget {
-
const CreatePostScreen({super.key});
+
/// Language options for posts
+
const Map<String, String> languages = {
+
'en': 'English',
+
'es': 'Spanish',
+
'pt': 'Portuguese',
+
'de': 'German',
+
'fr': 'French',
+
'ja': 'Japanese',
+
'ko': 'Korean',
+
'zh': 'Chinese',
+
};
+
+
/// Content limits from backend lexicon (social.coves.community.post)
+
/// Using grapheme limits as they are the user-facing character counts
+
const int kTitleMaxLength = 300;
+
const int kContentMaxLength = 10000;
+
+
/// Create Post Screen
+
///
+
/// Full-screen interface for creating a new post in a community.
+
///
+
/// Features:
+
/// - Community selector (required)
+
/// - Optional title, URL, thumbnail, and body fields
+
/// - Language dropdown and NSFW toggle
+
/// - Form validation (at least one of title/body/URL required)
+
/// - Loading states and error handling
+
/// - Keyboard handling with scroll support
+
class CreatePostScreen extends StatefulWidget {
+
const CreatePostScreen({this.onNavigateToFeed, super.key});
+
+
/// Callback to navigate to feed tab (used when in tab navigation)
+
final VoidCallback? onNavigateToFeed;
+
+
@override
+
State<CreatePostScreen> createState() => _CreatePostScreenState();
+
}
+
+
class _CreatePostScreenState extends State<CreatePostScreen>
+
with WidgetsBindingObserver {
+
// Text controllers
+
final TextEditingController _titleController = TextEditingController();
+
final TextEditingController _urlController = TextEditingController();
+
final TextEditingController _thumbnailController = TextEditingController();
+
final TextEditingController _bodyController = TextEditingController();
+
+
// Scroll and focus
+
final ScrollController _scrollController = ScrollController();
+
final FocusNode _titleFocusNode = FocusNode();
+
final FocusNode _urlFocusNode = FocusNode();
+
final FocusNode _thumbnailFocusNode = FocusNode();
+
final FocusNode _bodyFocusNode = FocusNode();
+
double _lastKeyboardHeight = 0;
+
+
// Form state
+
CommunityView? _selectedCommunity;
+
String _language = 'en';
+
bool _isNsfw = false;
+
bool _isSubmitting = false;
+
+
// Computed state
+
bool get _isFormValid {
+
return _selectedCommunity != null &&
+
(_titleController.text.trim().isNotEmpty ||
+
_bodyController.text.trim().isNotEmpty ||
+
_urlController.text.trim().isNotEmpty);
+
}
+
+
@override
+
void initState() {
+
super.initState();
+
WidgetsBinding.instance.addObserver(this);
+
// Listen to text changes to update button state
+
_titleController.addListener(_onTextChanged);
+
_urlController.addListener(_onTextChanged);
+
_bodyController.addListener(_onTextChanged);
+
}
+
+
@override
+
void dispose() {
+
WidgetsBinding.instance.removeObserver(this);
+
_titleController.dispose();
+
_urlController.dispose();
+
_thumbnailController.dispose();
+
_bodyController.dispose();
+
_scrollController.dispose();
+
_titleFocusNode.dispose();
+
_urlFocusNode.dispose();
+
_thumbnailFocusNode.dispose();
+
_bodyFocusNode.dispose();
+
super.dispose();
+
}
+
+
@override
+
void didChangeMetrics() {
+
super.didChangeMetrics();
+
if (!mounted) {
+
return;
+
}
+
+
final keyboardHeight = View.of(context).viewInsets.bottom;
+
+
// Detect keyboard closing and unfocus all text fields
+
if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
+
FocusManager.instance.primaryFocus?.unfocus();
+
}
+
+
_lastKeyboardHeight = keyboardHeight;
+
}
+
+
void _onTextChanged() {
+
// Force rebuild to update Post button state
+
setState(() {});
+
}
+
+
Future<void> _selectCommunity() async {
+
final result = await Navigator.push<CommunityView>(
+
context,
+
MaterialPageRoute(
+
builder: (context) => const CommunityPickerScreen(),
+
),
+
);
+
+
if (result != null && mounted) {
+
setState(() {
+
_selectedCommunity = result;
+
});
+
}
+
}
+
+
Future<void> _handleSubmit() async {
+
if (!_isFormValid || _isSubmitting) {
+
return;
+
}
+
+
setState(() {
+
_isSubmitting = true;
+
});
+
+
try {
+
final authProvider = context.read<AuthProvider>();
+
+
// Create API service with auth
+
final apiService = CovesApiService(
+
tokenGetter: authProvider.getAccessToken,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
);
+
+
// Build embed if URL is provided
+
ExternalEmbedInput? embed;
+
final url = _urlController.text.trim();
+
if (url.isNotEmpty) {
+
// Validate URL
+
final uri = Uri.tryParse(url);
+
if (uri == null ||
+
!uri.hasScheme ||
+
(!uri.scheme.startsWith('http'))) {
+
if (mounted) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
SnackBar(
+
content: const Text('Please enter a valid URL (http or https)'),
+
backgroundColor: Colors.red[700],
+
behavior: SnackBarBehavior.floating,
+
),
+
);
+
}
+
setState(() {
+
_isSubmitting = false;
+
});
+
return;
+
}
+
+
embed = ExternalEmbedInput(
+
uri: url,
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
: null,
+
thumb: _thumbnailController.text.trim().isNotEmpty
+
? _thumbnailController.text.trim()
+
: null,
+
);
+
}
+
+
// Build labels if NSFW is enabled
+
SelfLabels? labels;
+
if (_isNsfw) {
+
labels = const SelfLabels(values: [SelfLabel(val: 'nsfw')]);
+
}
+
+
// Create post
+
final response = await apiService.createPost(
+
community: _selectedCommunity!.did,
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
: null,
+
content: _bodyController.text.trim().isNotEmpty
+
? _bodyController.text.trim()
+
: null,
+
embed: embed,
+
langs: [_language],
+
labels: labels,
+
);
+
+
if (mounted) {
+
// Build optimistic post for immediate display
+
final optimisticPost = _buildOptimisticPost(
+
response: response,
+
authProvider: authProvider,
+
);
+
+
// Reset form first
+
_resetForm();
+
+
// Navigate to post detail with optimistic data
+
await Navigator.push(
+
context,
+
MaterialPageRoute(
+
builder: (context) => PostDetailScreen(
+
post: optimisticPost,
+
isOptimistic: true,
+
),
+
),
+
);
+
}
+
} on ApiException catch (e) {
+
if (mounted) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
SnackBar(
+
content: Text('Failed to create post: ${e.message}'),
+
backgroundColor: Colors.red[700],
+
behavior: SnackBarBehavior.floating,
+
),
+
);
+
}
+
} on Exception catch (e) {
+
if (mounted) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
SnackBar(
+
content: Text('Failed to create post: ${e.toString()}'),
+
backgroundColor: Colors.red[700],
+
behavior: SnackBarBehavior.floating,
+
),
+
);
+
}
+
} finally {
+
if (mounted) {
+
setState(() {
+
_isSubmitting = false;
+
});
+
}
+
}
+
}
+
+
void _resetForm() {
+
setState(() {
+
_titleController.clear();
+
_urlController.clear();
+
_thumbnailController.clear();
+
_bodyController.clear();
+
_selectedCommunity = null;
+
_language = 'en';
+
_isNsfw = false;
+
});
+
}
+
+
/// Build optimistic post for immediate display after creation
+
FeedViewPost _buildOptimisticPost({
+
required CreatePostResponse response,
+
required AuthProvider authProvider,
+
}) {
+
// Extract rkey from AT-URI (at://did/collection/rkey)
+
final uriParts = response.uri.split('/');
+
final rkey = uriParts.isNotEmpty ? uriParts.last : '';
+
+
// Build embed if URL was provided
+
PostEmbed? embed;
+
final url = _urlController.text.trim();
+
if (url.isNotEmpty) {
+
embed = PostEmbed(
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
uri: url,
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
: null,
+
thumb: _thumbnailController.text.trim().isNotEmpty
+
? _thumbnailController.text.trim()
+
: null,
+
),
+
data: {
+
r'$type': 'social.coves.embed.external',
+
'external': {
+
'uri': url,
+
if (_titleController.text.trim().isNotEmpty)
+
'title': _titleController.text.trim(),
+
if (_thumbnailController.text.trim().isNotEmpty)
+
'thumb': _thumbnailController.text.trim(),
+
},
+
},
+
);
+
}
+
+
final now = DateTime.now();
+
+
return FeedViewPost(
+
post: PostView(
+
uri: response.uri,
+
cid: response.cid,
+
rkey: rkey,
+
author: AuthorView(
+
did: authProvider.did ?? '',
+
handle: authProvider.handle ?? 'unknown',
+
displayName: null,
+
avatar: null,
+
),
+
community: CommunityRef(
+
did: _selectedCommunity!.did,
+
name: _selectedCommunity!.name,
+
handle: _selectedCommunity!.handle,
+
avatar: _selectedCommunity!.avatar,
+
),
+
createdAt: now,
+
indexedAt: now,
+
text: _bodyController.text.trim(),
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
: null,
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
+
embed: embed,
+
viewer: ViewerState(),
+
),
+
);
+
}
@override
Widget build(BuildContext context) {
-
return Scaffold(
-
backgroundColor: const Color(0xFF0B0F14),
-
appBar: AppBar(
-
backgroundColor: const Color(0xFF0B0F14),
-
foregroundColor: Colors.white,
+
final authProvider = context.watch<AuthProvider>();
+
final userHandle = authProvider.handle ?? 'Unknown';
+
+
return PopScope(
+
canPop: widget.onNavigateToFeed == null,
+
onPopInvokedWithResult: (didPop, result) {
+
if (!didPop && widget.onNavigateToFeed != null) {
+
widget.onNavigateToFeed!();
+
}
+
},
+
child: Scaffold(
+
backgroundColor: AppColors.background,
+
appBar: AppBar(
+
backgroundColor: AppColors.background,
+
surfaceTintColor: Colors.transparent,
+
foregroundColor: AppColors.textPrimary,
title: const Text('Create Post'),
+
elevation: 0,
automaticallyImplyLeading: false,
+
leading: IconButton(
+
icon: const Icon(Icons.close),
+
onPressed: () {
+
// Use callback if available (tab navigation), otherwise pop
+
if (widget.onNavigateToFeed != null) {
+
widget.onNavigateToFeed!();
+
} else {
+
Navigator.pop(context);
+
}
+
},
+
),
+
actions: [
+
Padding(
+
padding: const EdgeInsets.only(right: 8),
+
child: TextButton(
+
onPressed: _isFormValid && !_isSubmitting ? _handleSubmit : null,
+
style: TextButton.styleFrom(
+
backgroundColor: _isFormValid && !_isSubmitting
+
? AppColors.primary
+
: AppColors.textSecondary.withValues(alpha: 0.3),
+
foregroundColor: AppColors.textPrimary,
+
padding: const EdgeInsets.symmetric(
+
horizontal: 16,
+
vertical: 8,
+
),
+
shape: RoundedRectangleBorder(
+
borderRadius: BorderRadius.circular(20),
+
),
+
),
+
child:
+
_isSubmitting
+
? const SizedBox(
+
width: 16,
+
height: 16,
+
child: CircularProgressIndicator(
+
strokeWidth: 2,
+
valueColor: AlwaysStoppedAnimation<Color>(
+
AppColors.textPrimary,
+
),
+
),
+
)
+
: const Text('Post'),
+
),
+
),
+
],
),
-
body: const Center(
-
child: Padding(
-
padding: EdgeInsets.all(24),
+
body: SafeArea(
+
child: SingleChildScrollView(
+
controller: _scrollController,
+
padding: const EdgeInsets.all(16),
child: Column(
-
mainAxisAlignment: MainAxisAlignment.center,
+
crossAxisAlignment: CrossAxisAlignment.stretch,
+
children: [
+
// Community selector
+
_buildCommunitySelector(),
+
+
const SizedBox(height: 16),
+
+
// User info row
+
_buildUserInfo(userHandle),
+
+
const SizedBox(height: 24),
+
+
// Title field
+
_buildTextField(
+
controller: _titleController,
+
focusNode: _titleFocusNode,
+
hintText: 'Title',
+
maxLines: 1,
+
maxLength: kTitleMaxLength,
+
),
+
+
const SizedBox(height: 16),
+
+
// URL field
+
_buildTextField(
+
controller: _urlController,
+
focusNode: _urlFocusNode,
+
hintText: 'URL',
+
maxLines: 1,
+
keyboardType: TextInputType.url,
+
),
+
+
// Thumbnail field (only visible when URL is filled)
+
if (_urlController.text.trim().isNotEmpty) ...[
+
const SizedBox(height: 16),
+
_buildTextField(
+
controller: _thumbnailController,
+
focusNode: _thumbnailFocusNode,
+
hintText: 'Thumbnail URL',
+
maxLines: 1,
+
keyboardType: TextInputType.url,
+
),
+
],
+
+
const SizedBox(height: 16),
+
+
// Body field (multiline)
+
_buildTextField(
+
controller: _bodyController,
+
focusNode: _bodyFocusNode,
+
hintText: 'What are your thoughts?',
+
minLines: 8,
+
maxLines: null,
+
maxLength: kContentMaxLength,
+
),
+
+
const SizedBox(height: 24),
+
+
// Language dropdown and NSFW toggle
+
Row(
+
children: [
+
// Language dropdown
+
Expanded(
+
child: _buildLanguageDropdown(),
+
),
+
+
const SizedBox(width: 16),
+
+
// NSFW toggle
+
Expanded(
+
child: _buildNsfwToggle(),
+
),
+
],
+
),
+
+
const SizedBox(height: 24),
+
],
+
),
+
),
+
),
+
),
+
);
+
}
+
+
Widget _buildCommunitySelector() {
+
return Material(
+
color: Colors.transparent,
+
child: InkWell(
+
onTap: _selectCommunity,
+
borderRadius: BorderRadius.circular(12),
+
child: Container(
+
padding: const EdgeInsets.all(16),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
border: Border.all(color: AppColors.border),
+
borderRadius: BorderRadius.circular(12),
+
),
+
child: Row(
children: [
-
Icon(
-
Icons.add_circle_outline,
-
size: 64,
-
color: AppColors.primary,
-
),
-
SizedBox(height: 24),
-
Text(
-
'Create Post',
-
style: TextStyle(
-
fontSize: 28,
-
color: Colors.white,
-
fontWeight: FontWeight.bold,
+
const Icon(
+
Icons.workspaces_outlined,
+
color: AppColors.textSecondary,
+
size: 20,
+
),
+
const SizedBox(width: 12),
+
Expanded(
+
child: Text(
+
_selectedCommunity?.displayName ??
+
_selectedCommunity?.name ??
+
'Select a community',
+
style:
+
TextStyle(
+
color:
+
_selectedCommunity != null
+
? AppColors.textPrimary
+
: AppColors.textSecondary,
+
fontSize: 16,
+
),
+
maxLines: 1,
+
overflow: TextOverflow.ellipsis,
),
),
-
SizedBox(height: 16),
-
Text(
-
'Share your thoughts with the community',
-
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
-
textAlign: TextAlign.center,
+
const Icon(
+
Icons.chevron_right,
+
color: AppColors.textSecondary,
+
size: 20,
),
],
),
···
),
);
}
+
+
Widget _buildUserInfo(String handle) {
+
return Row(
+
children: [
+
const Icon(
+
Icons.person,
+
color: AppColors.textSecondary,
+
size: 16,
+
),
+
const SizedBox(width: 8),
+
Text(
+
'@$handle',
+
style: const TextStyle(
+
color: AppColors.textSecondary,
+
fontSize: 14,
+
),
+
),
+
],
+
);
+
}
+
+
Widget _buildTextField({
+
required TextEditingController controller,
+
required String hintText,
+
FocusNode? focusNode,
+
int? maxLines,
+
int? minLines,
+
int? maxLength,
+
TextInputType? keyboardType,
+
TextInputAction? textInputAction,
+
}) {
+
// For multiline fields, use newline action and multiline keyboard
+
final isMultiline = minLines != null && minLines > 1;
+
final effectiveKeyboardType =
+
keyboardType ?? (isMultiline ? TextInputType.multiline : TextInputType.text);
+
final effectiveTextInputAction =
+
textInputAction ?? (isMultiline ? TextInputAction.newline : TextInputAction.next);
+
+
return TextField(
+
controller: controller,
+
focusNode: focusNode,
+
maxLines: maxLines,
+
minLines: minLines,
+
maxLength: maxLength,
+
keyboardType: effectiveKeyboardType,
+
textInputAction: effectiveTextInputAction,
+
textCapitalization: TextCapitalization.sentences,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 16,
+
),
+
decoration: InputDecoration(
+
hintText: hintText,
+
hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
+
filled: true,
+
fillColor: const Color(0xFF1A2028),
+
counterStyle: const TextStyle(color: AppColors.textSecondary),
+
border: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(color: Color(0xFF2A3441)),
+
),
+
enabledBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(color: Color(0xFF2A3441)),
+
),
+
focusedBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(
+
color: AppColors.primary,
+
width: 2,
+
),
+
),
+
contentPadding: const EdgeInsets.all(16),
+
),
+
);
+
}
+
+
Widget _buildLanguageDropdown() {
+
return Container(
+
padding: const EdgeInsets.symmetric(horizontal: 12),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
border: Border.all(color: AppColors.border),
+
borderRadius: BorderRadius.circular(12),
+
),
+
child: DropdownButtonHideUnderline(
+
child: DropdownButton<String>(
+
value: _language,
+
dropdownColor: AppColors.backgroundSecondary,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 16,
+
),
+
icon: const Icon(
+
Icons.arrow_drop_down,
+
color: AppColors.textSecondary,
+
),
+
items:
+
languages.entries.map((entry) {
+
return DropdownMenuItem<String>(
+
value: entry.key,
+
child: Text(entry.value),
+
);
+
}).toList(),
+
onChanged: (value) {
+
if (value != null) {
+
setState(() {
+
_language = value;
+
});
+
}
+
},
+
),
+
),
+
);
+
}
+
+
Widget _buildNsfwToggle() {
+
return Container(
+
padding: const EdgeInsets.symmetric(horizontal: 12),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
border: Border.all(color: AppColors.border),
+
borderRadius: BorderRadius.circular(12),
+
),
+
child: Row(
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+
children: [
+
const Text(
+
'NSFW',
+
style: TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 16,
+
),
+
),
+
Transform.scale(
+
scale: 0.8,
+
child: Switch.adaptive(
+
value: _isNsfw,
+
activeTrackColor: AppColors.primary,
+
onChanged: (value) {
+
setState(() {
+
_isNsfw = value;
+
});
+
},
+
),
+
),
+
],
+
),
+
);
+
}
}
+7 -1
lib/screens/home/main_shell_screen.dart
···
});
}
+
void _onNavigateToFeed() {
+
setState(() {
+
_selectedIndex = 0; // Switch to feed tab
+
});
+
}
+
@override
Widget build(BuildContext context) {
return Scaffold(
···
children: [
FeedScreen(onSearchTap: _onCommunitiesTap),
const CommunitiesScreen(),
-
const CreatePostScreen(),
+
CreatePostScreen(onNavigateToFeed: _onNavigateToFeed),
const NotificationsScreen(),
const ProfileScreen(),
],
+368
test/models/community_test.dart
···
+
import 'package:coves_flutter/models/community.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
+
void main() {
+
group('CommunitiesResponse', () {
+
test('should parse valid JSON with communities', () {
+
final json = {
+
'communities': [
+
{
+
'did': 'did:plc:community1',
+
'name': 'test-community',
+
'handle': 'test.coves.social',
+
'displayName': 'Test Community',
+
'description': 'A test community',
+
'avatar': 'https://example.com/avatar.jpg',
+
'visibility': 'public',
+
'subscriberCount': 100,
+
'memberCount': 50,
+
'postCount': 200,
+
},
+
],
+
'cursor': 'next-cursor',
+
};
+
+
final response = CommunitiesResponse.fromJson(json);
+
+
expect(response.communities.length, 1);
+
expect(response.cursor, 'next-cursor');
+
expect(response.communities[0].did, 'did:plc:community1');
+
expect(response.communities[0].name, 'test-community');
+
expect(response.communities[0].displayName, 'Test Community');
+
});
+
+
test('should handle null communities array', () {
+
final json = {
+
'communities': null,
+
'cursor': null,
+
};
+
+
final response = CommunitiesResponse.fromJson(json);
+
+
expect(response.communities, isEmpty);
+
expect(response.cursor, null);
+
});
+
+
test('should handle empty communities array', () {
+
final json = {
+
'communities': [],
+
'cursor': null,
+
};
+
+
final response = CommunitiesResponse.fromJson(json);
+
+
expect(response.communities, isEmpty);
+
expect(response.cursor, null);
+
});
+
+
test('should parse without cursor', () {
+
final json = {
+
'communities': [
+
{
+
'did': 'did:plc:community1',
+
'name': 'test-community',
+
},
+
],
+
};
+
+
final response = CommunitiesResponse.fromJson(json);
+
+
expect(response.cursor, null);
+
expect(response.communities.length, 1);
+
});
+
});
+
+
group('CommunityView', () {
+
test('should parse complete JSON with all fields', () {
+
final json = {
+
'did': 'did:plc:community1',
+
'name': 'test-community',
+
'handle': 'test.coves.social',
+
'displayName': 'Test Community',
+
'description': 'A community for testing',
+
'avatar': 'https://example.com/avatar.jpg',
+
'visibility': 'public',
+
'subscriberCount': 1000,
+
'memberCount': 500,
+
'postCount': 2500,
+
'viewer': {
+
'subscribed': true,
+
'member': false,
+
},
+
};
+
+
final community = CommunityView.fromJson(json);
+
+
expect(community.did, 'did:plc:community1');
+
expect(community.name, 'test-community');
+
expect(community.handle, 'test.coves.social');
+
expect(community.displayName, 'Test Community');
+
expect(community.description, 'A community for testing');
+
expect(community.avatar, 'https://example.com/avatar.jpg');
+
expect(community.visibility, 'public');
+
expect(community.subscriberCount, 1000);
+
expect(community.memberCount, 500);
+
expect(community.postCount, 2500);
+
expect(community.viewer, isNotNull);
+
expect(community.viewer!.subscribed, true);
+
expect(community.viewer!.member, false);
+
});
+
+
test('should parse minimal JSON with required fields only', () {
+
final json = {
+
'did': 'did:plc:community1',
+
'name': 'test-community',
+
};
+
+
final community = CommunityView.fromJson(json);
+
+
expect(community.did, 'did:plc:community1');
+
expect(community.name, 'test-community');
+
expect(community.handle, null);
+
expect(community.displayName, null);
+
expect(community.description, null);
+
expect(community.avatar, null);
+
expect(community.visibility, null);
+
expect(community.subscriberCount, null);
+
expect(community.memberCount, null);
+
expect(community.postCount, null);
+
expect(community.viewer, null);
+
});
+
+
test('should handle null optional fields', () {
+
final json = {
+
'did': 'did:plc:community1',
+
'name': 'test-community',
+
'handle': null,
+
'displayName': null,
+
'description': null,
+
'avatar': null,
+
'visibility': null,
+
'subscriberCount': null,
+
'memberCount': null,
+
'postCount': null,
+
'viewer': null,
+
};
+
+
final community = CommunityView.fromJson(json);
+
+
expect(community.did, 'did:plc:community1');
+
expect(community.name, 'test-community');
+
expect(community.handle, null);
+
expect(community.displayName, null);
+
expect(community.description, null);
+
expect(community.avatar, null);
+
expect(community.visibility, null);
+
expect(community.subscriberCount, null);
+
expect(community.memberCount, null);
+
expect(community.postCount, null);
+
expect(community.viewer, null);
+
});
+
});
+
+
group('CommunityViewerState', () {
+
test('should parse with all fields', () {
+
final json = {
+
'subscribed': true,
+
'member': true,
+
};
+
+
final viewer = CommunityViewerState.fromJson(json);
+
+
expect(viewer.subscribed, true);
+
expect(viewer.member, true);
+
});
+
+
test('should parse with false values', () {
+
final json = {
+
'subscribed': false,
+
'member': false,
+
};
+
+
final viewer = CommunityViewerState.fromJson(json);
+
+
expect(viewer.subscribed, false);
+
expect(viewer.member, false);
+
});
+
+
test('should handle null values', () {
+
final json = {
+
'subscribed': null,
+
'member': null,
+
};
+
+
final viewer = CommunityViewerState.fromJson(json);
+
+
expect(viewer.subscribed, null);
+
expect(viewer.member, null);
+
});
+
+
test('should handle missing fields', () {
+
final json = <String, dynamic>{};
+
+
final viewer = CommunityViewerState.fromJson(json);
+
+
expect(viewer.subscribed, null);
+
expect(viewer.member, null);
+
});
+
});
+
+
group('CreatePostResponse', () {
+
test('should parse valid JSON', () {
+
final json = {
+
'uri': 'at://did:plc:test/social.coves.community.post/123',
+
'cid': 'bafyreicid123',
+
};
+
+
final response = CreatePostResponse.fromJson(json);
+
+
expect(response.uri, 'at://did:plc:test/social.coves.community.post/123');
+
expect(response.cid, 'bafyreicid123');
+
});
+
+
test('should be const constructible', () {
+
const response = CreatePostResponse(
+
uri: 'at://did:plc:test/post/123',
+
cid: 'cid123',
+
);
+
+
expect(response.uri, 'at://did:plc:test/post/123');
+
expect(response.cid, 'cid123');
+
});
+
});
+
+
group('ExternalEmbedInput', () {
+
test('should serialize complete JSON', () {
+
const embed = ExternalEmbedInput(
+
uri: 'https://example.com/article',
+
title: 'Article Title',
+
description: 'Article description',
+
thumb: 'https://example.com/thumb.jpg',
+
);
+
+
final json = embed.toJson();
+
+
expect(json['uri'], 'https://example.com/article');
+
expect(json['title'], 'Article Title');
+
expect(json['description'], 'Article description');
+
expect(json['thumb'], 'https://example.com/thumb.jpg');
+
});
+
+
test('should serialize minimal JSON with only required fields', () {
+
const embed = ExternalEmbedInput(
+
uri: 'https://example.com/article',
+
);
+
+
final json = embed.toJson();
+
+
expect(json['uri'], 'https://example.com/article');
+
expect(json.containsKey('title'), false);
+
expect(json.containsKey('description'), false);
+
expect(json.containsKey('thumb'), false);
+
});
+
+
test('should be const constructible', () {
+
const embed = ExternalEmbedInput(
+
uri: 'https://example.com',
+
title: 'Test',
+
);
+
+
expect(embed.uri, 'https://example.com');
+
expect(embed.title, 'Test');
+
});
+
});
+
+
group('SelfLabels', () {
+
test('should serialize to JSON', () {
+
const labels = SelfLabels(
+
values: [
+
SelfLabel(val: 'nsfw'),
+
SelfLabel(val: 'spoiler'),
+
],
+
);
+
+
final json = labels.toJson();
+
+
expect(json['values'], isA<List>());
+
expect((json['values'] as List).length, 2);
+
expect((json['values'] as List)[0]['val'], 'nsfw');
+
expect((json['values'] as List)[1]['val'], 'spoiler');
+
});
+
+
test('should be const constructible', () {
+
const labels = SelfLabels(
+
values: [SelfLabel(val: 'nsfw')],
+
);
+
+
expect(labels.values.length, 1);
+
expect(labels.values[0].val, 'nsfw');
+
});
+
});
+
+
group('SelfLabel', () {
+
test('should serialize to JSON', () {
+
const label = SelfLabel(val: 'nsfw');
+
+
final json = label.toJson();
+
+
expect(json['val'], 'nsfw');
+
});
+
+
test('should be const constructible', () {
+
const label = SelfLabel(val: 'spoiler');
+
+
expect(label.val, 'spoiler');
+
});
+
});
+
+
group('CreatePostRequest', () {
+
test('should serialize complete request', () {
+
final request = CreatePostRequest(
+
community: 'did:plc:community1',
+
title: 'Test Post',
+
content: 'Post content here',
+
embed: const ExternalEmbedInput(
+
uri: 'https://example.com',
+
title: 'Link Title',
+
),
+
langs: ['en', 'es'],
+
labels: const SelfLabels(values: [SelfLabel(val: 'nsfw')]),
+
);
+
+
final json = request.toJson();
+
+
expect(json['community'], 'did:plc:community1');
+
expect(json['title'], 'Test Post');
+
expect(json['content'], 'Post content here');
+
expect(json['embed'], isA<Map>());
+
expect(json['langs'], ['en', 'es']);
+
expect(json['labels'], isA<Map>());
+
});
+
+
test('should serialize minimal request with only required fields', () {
+
final request = CreatePostRequest(
+
community: 'did:plc:community1',
+
);
+
+
final json = request.toJson();
+
+
expect(json['community'], 'did:plc:community1');
+
expect(json.containsKey('title'), false);
+
expect(json.containsKey('content'), false);
+
expect(json.containsKey('embed'), false);
+
expect(json.containsKey('langs'), false);
+
expect(json.containsKey('labels'), false);
+
});
+
+
test('should not include empty langs array', () {
+
final request = CreatePostRequest(
+
community: 'did:plc:community1',
+
langs: [],
+
);
+
+
final json = request.toJson();
+
+
expect(json.containsKey('langs'), false);
+
});
+
});
+
}
+269
test/screens/community_picker_screen_test.dart
···
+
import 'package:coves_flutter/models/community.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
+
void main() {
+
// Note: Full widget tests for CommunityPickerScreen require mocking the API
+
// service and proper timer management. The core business logic is thoroughly
+
// tested in the unit test groups below (search filtering, count formatting,
+
// description building). Widget integration tests would need a mock API service
+
// to avoid real network calls and pending timer issues from the search debounce.
+
+
group('CommunityPickerScreen Search Filtering', () {
+
test('client-side filtering should match name', () {
+
final communities = [
+
CommunityView(did: 'did:1', name: 'programming'),
+
CommunityView(did: 'did:2', name: 'gaming'),
+
CommunityView(did: 'did:3', name: 'music'),
+
];
+
+
final query = 'prog';
+
+
final filtered = communities.where((community) {
+
final name = community.name.toLowerCase();
+
return name.contains(query.toLowerCase());
+
}).toList();
+
+
expect(filtered.length, 1);
+
expect(filtered[0].name, 'programming');
+
});
+
+
test('client-side filtering should match displayName', () {
+
final communities = [
+
CommunityView(
+
did: 'did:1',
+
name: 'prog',
+
displayName: 'Programming Discussion',
+
),
+
CommunityView(did: 'did:2', name: 'gaming', displayName: 'Gaming'),
+
CommunityView(did: 'did:3', name: 'music', displayName: 'Music'),
+
];
+
+
final query = 'discussion';
+
+
final filtered = communities.where((community) {
+
final name = community.name.toLowerCase();
+
final displayName = community.displayName?.toLowerCase() ?? '';
+
return name.contains(query.toLowerCase()) ||
+
displayName.contains(query.toLowerCase());
+
}).toList();
+
+
expect(filtered.length, 1);
+
expect(filtered[0].displayName, 'Programming Discussion');
+
});
+
+
test('client-side filtering should match description', () {
+
final communities = [
+
CommunityView(
+
did: 'did:1',
+
name: 'prog',
+
description: 'A place to discuss coding and software',
+
),
+
CommunityView(
+
did: 'did:2',
+
name: 'gaming',
+
description: 'Gaming news and discussions',
+
),
+
CommunityView(
+
did: 'did:3',
+
name: 'music',
+
description: 'Music appreciation',
+
),
+
];
+
+
final query = 'software';
+
+
final filtered = communities.where((community) {
+
final name = community.name.toLowerCase();
+
final description = community.description?.toLowerCase() ?? '';
+
return name.contains(query.toLowerCase()) ||
+
description.contains(query.toLowerCase());
+
}).toList();
+
+
expect(filtered.length, 1);
+
expect(filtered[0].name, 'prog');
+
});
+
+
test('client-side filtering should be case insensitive', () {
+
final communities = [
+
CommunityView(did: 'did:1', name: 'Programming'),
+
CommunityView(did: 'did:2', name: 'GAMING'),
+
CommunityView(did: 'did:3', name: 'music'),
+
];
+
+
final query = 'PROG';
+
+
final filtered = communities.where((community) {
+
final name = community.name.toLowerCase();
+
return name.contains(query.toLowerCase());
+
}).toList();
+
+
expect(filtered.length, 1);
+
expect(filtered[0].name, 'Programming');
+
});
+
+
test('empty query should return all communities', () {
+
final communities = [
+
CommunityView(did: 'did:1', name: 'programming'),
+
CommunityView(did: 'did:2', name: 'gaming'),
+
CommunityView(did: 'did:3', name: 'music'),
+
];
+
+
final query = '';
+
+
List<CommunityView> filtered;
+
if (query.isEmpty) {
+
filtered = communities;
+
} else {
+
filtered = communities.where((community) {
+
final name = community.name.toLowerCase();
+
return name.contains(query.toLowerCase());
+
}).toList();
+
}
+
+
expect(filtered.length, 3);
+
});
+
+
test('no match should return empty list', () {
+
final communities = [
+
CommunityView(did: 'did:1', name: 'programming'),
+
CommunityView(did: 'did:2', name: 'gaming'),
+
CommunityView(did: 'did:3', name: 'music'),
+
];
+
+
final query = 'xyz123';
+
+
final filtered = communities.where((community) {
+
final name = community.name.toLowerCase();
+
final displayName = community.displayName?.toLowerCase() ?? '';
+
final description = community.description?.toLowerCase() ?? '';
+
return name.contains(query.toLowerCase()) ||
+
displayName.contains(query.toLowerCase()) ||
+
description.contains(query.toLowerCase());
+
}).toList();
+
+
expect(filtered.length, 0);
+
});
+
});
+
+
group('CommunityPickerScreen Member Count Formatting', () {
+
String formatCount(int? count) {
+
if (count == null) {
+
return '0';
+
}
+
if (count >= 1000000) {
+
return '${(count / 1000000).toStringAsFixed(1)}M';
+
} else if (count >= 1000) {
+
return '${(count / 1000).toStringAsFixed(1)}K';
+
}
+
return count.toString();
+
}
+
+
test('should format null count as 0', () {
+
expect(formatCount(null), '0');
+
});
+
+
test('should format small numbers as-is', () {
+
expect(formatCount(0), '0');
+
expect(formatCount(1), '1');
+
expect(formatCount(100), '100');
+
expect(formatCount(999), '999');
+
});
+
+
test('should format thousands with K suffix', () {
+
expect(formatCount(1000), '1.0K');
+
expect(formatCount(1500), '1.5K');
+
expect(formatCount(10000), '10.0K');
+
expect(formatCount(999999), '1000.0K');
+
});
+
+
test('should format millions with M suffix', () {
+
expect(formatCount(1000000), '1.0M');
+
expect(formatCount(1500000), '1.5M');
+
expect(formatCount(10000000), '10.0M');
+
});
+
});
+
+
group('CommunityPickerScreen Description Building', () {
+
test('should build description with member count only', () {
+
const memberCount = 1000;
+
const subscriberCount = 0;
+
+
String formatCount(int count) {
+
if (count >= 1000) {
+
return '${(count / 1000).toStringAsFixed(1)}K';
+
}
+
return count.toString();
+
}
+
+
var descriptionLine = '';
+
if (memberCount > 0) {
+
descriptionLine = '${formatCount(memberCount)} members';
+
}
+
+
expect(descriptionLine, '1.0K members');
+
});
+
+
test('should build description with member and subscriber counts', () {
+
const memberCount = 1000;
+
const subscriberCount = 500;
+
+
String formatCount(int count) {
+
if (count >= 1000) {
+
return '${(count / 1000).toStringAsFixed(1)}K';
+
}
+
return count.toString();
+
}
+
+
var descriptionLine = '';
+
if (memberCount > 0) {
+
descriptionLine = '${formatCount(memberCount)} members';
+
if (subscriberCount > 0) {
+
descriptionLine += ' ยท ${formatCount(subscriberCount)} subscribers';
+
}
+
}
+
+
expect(descriptionLine, '1.0K members ยท 500 subscribers');
+
});
+
+
test('should build description with subscriber count only', () {
+
const memberCount = 0;
+
const subscriberCount = 500;
+
+
String formatCount(int count) {
+
if (count >= 1000) {
+
return '${(count / 1000).toStringAsFixed(1)}K';
+
}
+
return count.toString();
+
}
+
+
var descriptionLine = '';
+
if (memberCount > 0) {
+
descriptionLine = '${formatCount(memberCount)} members';
+
} else if (subscriberCount > 0) {
+
descriptionLine = '${formatCount(subscriberCount)} subscribers';
+
}
+
+
expect(descriptionLine, '500 subscribers');
+
});
+
+
test('should append community description with separator', () {
+
const memberCount = 100;
+
const description = 'A great community';
+
+
String formatCount(int count) => count.toString();
+
+
var descriptionLine = '';
+
if (memberCount > 0) {
+
descriptionLine = '${formatCount(memberCount)} members';
+
}
+
if (description.isNotEmpty) {
+
if (descriptionLine.isNotEmpty) {
+
descriptionLine += ' ยท ';
+
}
+
descriptionLine += description;
+
}
+
+
expect(descriptionLine, '100 members ยท A great community');
+
});
+
});
+
}
+339
test/screens/create_post_screen_test.dart
···
+
import 'package:coves_flutter/models/community.dart';
+
import 'package:coves_flutter/providers/auth_provider.dart';
+
import 'package:coves_flutter/screens/home/create_post_screen.dart';
+
import 'package:flutter/material.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:provider/provider.dart';
+
+
// Fake AuthProvider for testing
+
class FakeAuthProvider extends AuthProvider {
+
bool _isAuthenticated = true;
+
String? _did = 'did:plc:testuser';
+
String? _handle = 'testuser.coves.social';
+
+
@override
+
bool get isAuthenticated => _isAuthenticated;
+
+
@override
+
String? get did => _did;
+
+
@override
+
String? get handle => _handle;
+
+
void setAuthenticated({required bool value, String? did, String? handle}) {
+
_isAuthenticated = value;
+
_did = did;
+
_handle = handle;
+
notifyListeners();
+
}
+
+
@override
+
Future<String?> getAccessToken() async {
+
return _isAuthenticated ? 'mock_access_token' : null;
+
}
+
+
@override
+
Future<bool> refreshToken() async {
+
return _isAuthenticated;
+
}
+
+
@override
+
Future<void> signOut() async {
+
_isAuthenticated = false;
+
_did = null;
+
_handle = null;
+
notifyListeners();
+
}
+
}
+
+
void main() {
+
group('CreatePostScreen Widget Tests', () {
+
late FakeAuthProvider fakeAuthProvider;
+
+
setUp(() {
+
fakeAuthProvider = FakeAuthProvider();
+
});
+
+
Widget createTestWidget({VoidCallback? onNavigateToFeed}) {
+
return MultiProvider(
+
providers: [
+
ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
+
],
+
child: MaterialApp(
+
home: CreatePostScreen(onNavigateToFeed: onNavigateToFeed),
+
),
+
);
+
}
+
+
testWidgets('should display Create Post title', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.text('Create Post'), findsOneWidget);
+
});
+
+
testWidgets('should display user handle', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.text('@testuser.coves.social'), findsOneWidget);
+
});
+
+
testWidgets('should display community selector', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.text('Select a community'), findsOneWidget);
+
});
+
+
testWidgets('should display title field', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.widgetWithText(TextField, 'Title'), findsOneWidget);
+
});
+
+
testWidgets('should display URL field', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.widgetWithText(TextField, 'URL'), findsOneWidget);
+
});
+
+
testWidgets('should display body field', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(
+
find.widgetWithText(TextField, 'What are your thoughts?'),
+
findsOneWidget,
+
);
+
});
+
+
testWidgets('should display language dropdown', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Default language should be English
+
expect(find.text('English'), findsOneWidget);
+
});
+
+
testWidgets('should display NSFW toggle', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.text('NSFW'), findsOneWidget);
+
expect(find.byType(Switch), findsOneWidget);
+
});
+
+
testWidgets('should have disabled Post button initially', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Find the Post button
+
final postButton = find.widgetWithText(TextButton, 'Post');
+
expect(postButton, findsOneWidget);
+
+
// Button should be disabled (no community selected, no content)
+
final button = tester.widget<TextButton>(postButton);
+
expect(button.onPressed, isNull);
+
});
+
+
testWidgets('should enable Post button when title is entered and community selected', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Enter a title
+
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Test Post');
+
await tester.pumpAndSettle();
+
+
// Post button should still be disabled (no community selected)
+
final postButton = find.widgetWithText(TextButton, 'Post');
+
final button = tester.widget<TextButton>(postButton);
+
expect(button.onPressed, isNull);
+
});
+
+
testWidgets('should toggle NSFW switch', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Find the switch
+
final switchWidget = find.byType(Switch);
+
expect(switchWidget, findsOneWidget);
+
+
// Initially should be off
+
Switch switchBefore = tester.widget<Switch>(switchWidget);
+
expect(switchBefore.value, false);
+
+
// Scroll to make switch visible, then tap
+
await tester.ensureVisible(switchWidget);
+
await tester.pumpAndSettle();
+
await tester.tap(switchWidget);
+
await tester.pumpAndSettle();
+
+
// Should be on now
+
Switch switchAfter = tester.widget<Switch>(switchWidget);
+
expect(switchAfter.value, true);
+
});
+
+
testWidgets('should show thumbnail field when URL is entered', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Initially no thumbnail field
+
expect(find.widgetWithText(TextField, 'Thumbnail URL'), findsNothing);
+
+
// Enter a URL
+
await tester.enterText(
+
find.widgetWithText(TextField, 'URL'),
+
'https://example.com',
+
);
+
await tester.pumpAndSettle();
+
+
// Thumbnail field should now be visible
+
expect(find.widgetWithText(TextField, 'Thumbnail URL'), findsOneWidget);
+
});
+
+
testWidgets('should hide thumbnail field when URL is cleared', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Enter a URL
+
final urlField = find.widgetWithText(TextField, 'URL');
+
await tester.enterText(urlField, 'https://example.com');
+
await tester.pumpAndSettle();
+
+
// Thumbnail field should be visible
+
expect(find.widgetWithText(TextField, 'Thumbnail URL'), findsOneWidget);
+
+
// Clear the URL
+
await tester.enterText(urlField, '');
+
await tester.pumpAndSettle();
+
+
// Thumbnail field should be hidden
+
expect(find.widgetWithText(TextField, 'Thumbnail URL'), findsNothing);
+
});
+
+
testWidgets('should display close button', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
expect(find.byIcon(Icons.close), findsOneWidget);
+
});
+
+
testWidgets('should call onNavigateToFeed when close button is tapped', (tester) async {
+
bool callbackCalled = false;
+
+
await tester.pumpWidget(
+
createTestWidget(onNavigateToFeed: () => callbackCalled = true),
+
);
+
await tester.pumpAndSettle();
+
+
await tester.tap(find.byIcon(Icons.close));
+
await tester.pumpAndSettle();
+
+
expect(callbackCalled, true);
+
});
+
+
testWidgets('should have character limit on title field', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Find the title TextField
+
final titleField = find.widgetWithText(TextField, 'Title');
+
final textField = tester.widget<TextField>(titleField);
+
+
// Should have maxLength set to 300 (kTitleMaxLength)
+
expect(textField.maxLength, 300);
+
});
+
+
testWidgets('should have character limit on body field', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Find the body TextField
+
final bodyField = find.widgetWithText(TextField, 'What are your thoughts?');
+
final textField = tester.widget<TextField>(bodyField);
+
+
// Should have maxLength set to 10000 (kContentMaxLength)
+
expect(textField.maxLength, 10000);
+
});
+
+
testWidgets('should be scrollable', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Should have a SingleChildScrollView
+
expect(find.byType(SingleChildScrollView), findsOneWidget);
+
});
+
});
+
+
group('CreatePostScreen Form Validation', () {
+
late FakeAuthProvider fakeAuthProvider;
+
+
setUp(() {
+
fakeAuthProvider = FakeAuthProvider();
+
});
+
+
Widget createTestWidget() {
+
return MultiProvider(
+
providers: [
+
ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
+
],
+
child: const MaterialApp(home: CreatePostScreen()),
+
);
+
}
+
+
testWidgets('form is invalid with no community and no content', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
final postButton = find.widgetWithText(TextButton, 'Post');
+
final button = tester.widget<TextButton>(postButton);
+
expect(button.onPressed, isNull);
+
});
+
+
testWidgets('form is invalid with content but no community', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Enter title
+
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Test');
+
await tester.pumpAndSettle();
+
+
final postButton = find.widgetWithText(TextButton, 'Post');
+
final button = tester.widget<TextButton>(postButton);
+
expect(button.onPressed, isNull);
+
});
+
+
testWidgets('entering text updates form state', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Enter title
+
await tester.enterText(
+
find.widgetWithText(TextField, 'Title'),
+
'My Test Post',
+
);
+
await tester.pumpAndSettle();
+
+
// Verify text was entered
+
expect(find.text('My Test Post'), findsOneWidget);
+
});
+
+
testWidgets('entering body text updates form state', (tester) async {
+
await tester.pumpWidget(createTestWidget());
+
await tester.pumpAndSettle();
+
+
// Enter body
+
await tester.enterText(
+
find.widgetWithText(TextField, 'What are your thoughts?'),
+
'This is my post content',
+
);
+
await tester.pumpAndSettle();
+
+
// Verify text was entered
+
expect(find.text('This is my post content'), findsOneWidget);
+
});
+
});
+
}
+463
test/services/coves_api_service_community_test.dart
···
+
import 'package:coves_flutter/models/community.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/coves_api_service.dart';
+
import 'package:dio/dio.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:http_mock_adapter/http_mock_adapter.dart';
+
+
void main() {
+
TestWidgetsFlutterBinding.ensureInitialized();
+
+
group('CovesApiService - listCommunities', () {
+
late Dio dio;
+
late DioAdapter dioAdapter;
+
late CovesApiService apiService;
+
+
setUp(() {
+
dio = Dio(BaseOptions(baseUrl: 'https://api.test.coves.social'));
+
dioAdapter = DioAdapter(dio: dio);
+
apiService = CovesApiService(
+
dio: dio,
+
tokenGetter: () async => 'test-token',
+
);
+
});
+
+
tearDown(() {
+
apiService.dispose();
+
});
+
+
test('should successfully fetch communities', () async {
+
final mockResponse = {
+
'communities': [
+
{
+
'did': 'did:plc:community1',
+
'name': 'test-community-1',
+
'displayName': 'Test Community 1',
+
'subscriberCount': 100,
+
'memberCount': 50,
+
},
+
{
+
'did': 'did:plc:community2',
+
'name': 'test-community-2',
+
'displayName': 'Test Community 2',
+
'subscriberCount': 200,
+
'memberCount': 100,
+
},
+
],
+
'cursor': 'next-cursor',
+
};
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(200, mockResponse),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
},
+
);
+
+
final response = await apiService.listCommunities();
+
+
expect(response, isA<CommunitiesResponse>());
+
expect(response.communities.length, 2);
+
expect(response.cursor, 'next-cursor');
+
expect(response.communities[0].did, 'did:plc:community1');
+
expect(response.communities[0].name, 'test-community-1');
+
expect(response.communities[1].did, 'did:plc:community2');
+
});
+
+
test('should handle empty communities response', () async {
+
final mockResponse = {
+
'communities': [],
+
'cursor': null,
+
};
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(200, mockResponse),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
},
+
);
+
+
final response = await apiService.listCommunities();
+
+
expect(response.communities, isEmpty);
+
expect(response.cursor, null);
+
});
+
+
test('should handle null communities array', () async {
+
final mockResponse = {
+
'communities': null,
+
'cursor': null,
+
};
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(200, mockResponse),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
},
+
);
+
+
final response = await apiService.listCommunities();
+
+
expect(response.communities, isEmpty);
+
});
+
+
test('should fetch communities with custom limit', () async {
+
final mockResponse = {
+
'communities': [],
+
'cursor': null,
+
};
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(200, mockResponse),
+
queryParameters: {
+
'limit': 25,
+
'sort': 'popular',
+
},
+
);
+
+
final response = await apiService.listCommunities(limit: 25);
+
+
expect(response, isA<CommunitiesResponse>());
+
});
+
+
test('should fetch communities with cursor for pagination', () async {
+
const cursor = 'pagination-cursor-123';
+
+
final mockResponse = {
+
'communities': [
+
{
+
'did': 'did:plc:community3',
+
'name': 'paginated-community',
+
},
+
],
+
'cursor': 'next-cursor-456',
+
};
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(200, mockResponse),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
'cursor': cursor,
+
},
+
);
+
+
final response = await apiService.listCommunities(cursor: cursor);
+
+
expect(response.communities.length, 1);
+
expect(response.cursor, 'next-cursor-456');
+
});
+
+
test('should fetch communities with custom sort', () async {
+
final mockResponse = {
+
'communities': [],
+
'cursor': null,
+
};
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(200, mockResponse),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'new',
+
},
+
);
+
+
final response = await apiService.listCommunities(sort: 'new');
+
+
expect(response, isA<CommunitiesResponse>());
+
});
+
+
test('should handle 401 unauthorized error', () async {
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Invalid token',
+
}),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
},
+
);
+
+
expect(
+
() => apiService.listCommunities(),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should handle 500 server error', () async {
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.reply(500, {
+
'error': 'InternalServerError',
+
'message': 'Database error',
+
}),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
},
+
);
+
+
expect(
+
() => apiService.listCommunities(),
+
throwsA(isA<ServerException>()),
+
);
+
});
+
+
test('should handle network timeout', () async {
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.list',
+
(server) => server.throws(
+
408,
+
DioException.connectionTimeout(
+
timeout: const Duration(seconds: 30),
+
requestOptions: RequestOptions(),
+
),
+
),
+
queryParameters: {
+
'limit': 50,
+
'sort': 'popular',
+
},
+
);
+
+
expect(
+
() => apiService.listCommunities(),
+
throwsA(isA<NetworkException>()),
+
);
+
});
+
});
+
+
group('CovesApiService - createPost', () {
+
late Dio dio;
+
late DioAdapter dioAdapter;
+
late CovesApiService apiService;
+
+
setUp(() {
+
dio = Dio(BaseOptions(baseUrl: 'https://api.test.coves.social'));
+
dioAdapter = DioAdapter(dio: dio);
+
apiService = CovesApiService(
+
dio: dio,
+
tokenGetter: () async => 'test-token',
+
);
+
});
+
+
tearDown(() {
+
apiService.dispose();
+
});
+
+
test('should successfully create a post with all fields', () async {
+
final mockResponse = {
+
'uri': 'at://did:plc:user/social.coves.community.post/123',
+
'cid': 'bafyreicid123',
+
};
+
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(200, mockResponse),
+
data: {
+
'community': 'did:plc:community1',
+
'title': 'Test Post Title',
+
'content': 'Test post content',
+
'embed': {
+
'uri': 'https://example.com/article',
+
'title': 'Article Title',
+
},
+
'langs': ['en'],
+
'labels': {
+
'values': [
+
{'val': 'nsfw'},
+
],
+
},
+
},
+
);
+
+
final response = await apiService.createPost(
+
community: 'did:plc:community1',
+
title: 'Test Post Title',
+
content: 'Test post content',
+
embed: const ExternalEmbedInput(
+
uri: 'https://example.com/article',
+
title: 'Article Title',
+
),
+
langs: ['en'],
+
labels: const SelfLabels(values: [SelfLabel(val: 'nsfw')]),
+
);
+
+
expect(response, isA<CreatePostResponse>());
+
expect(response.uri, 'at://did:plc:user/social.coves.community.post/123');
+
expect(response.cid, 'bafyreicid123');
+
});
+
+
test('should successfully create a minimal post', () async {
+
final mockResponse = {
+
'uri': 'at://did:plc:user/social.coves.community.post/456',
+
'cid': 'bafyreicid456',
+
};
+
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(200, mockResponse),
+
data: {
+
'community': 'did:plc:community1',
+
'title': 'Just a title',
+
},
+
);
+
+
final response = await apiService.createPost(
+
community: 'did:plc:community1',
+
title: 'Just a title',
+
);
+
+
expect(response, isA<CreatePostResponse>());
+
expect(response.uri, 'at://did:plc:user/social.coves.community.post/456');
+
});
+
+
test('should successfully create a link post', () async {
+
final mockResponse = {
+
'uri': 'at://did:plc:user/social.coves.community.post/789',
+
'cid': 'bafyreicid789',
+
};
+
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(200, mockResponse),
+
data: {
+
'community': 'did:plc:community1',
+
'embed': {
+
'uri': 'https://example.com/article',
+
},
+
},
+
);
+
+
final response = await apiService.createPost(
+
community: 'did:plc:community1',
+
embed: const ExternalEmbedInput(uri: 'https://example.com/article'),
+
);
+
+
expect(response, isA<CreatePostResponse>());
+
});
+
+
test('should handle 401 unauthorized error', () async {
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Authentication required',
+
}),
+
data: {
+
'community': 'did:plc:community1',
+
'title': 'Test',
+
},
+
);
+
+
expect(
+
() => apiService.createPost(
+
community: 'did:plc:community1',
+
title: 'Test',
+
),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should handle 404 community not found', () async {
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(404, {
+
'error': 'NotFound',
+
'message': 'Community not found',
+
}),
+
data: {
+
'community': 'did:plc:nonexistent',
+
'title': 'Test',
+
},
+
);
+
+
expect(
+
() => apiService.createPost(
+
community: 'did:plc:nonexistent',
+
title: 'Test',
+
),
+
throwsA(isA<NotFoundException>()),
+
);
+
});
+
+
test('should handle 400 validation error', () async {
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(400, {
+
'error': 'ValidationError',
+
'message': 'Title exceeds maximum length',
+
}),
+
data: {
+
'community': 'did:plc:community1',
+
'title': 'a' * 1000, // Very long title
+
},
+
);
+
+
expect(
+
() => apiService.createPost(
+
community: 'did:plc:community1',
+
title: 'a' * 1000,
+
),
+
throwsA(isA<ApiException>()),
+
);
+
});
+
+
test('should handle 500 server error', () async {
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.reply(500, {
+
'error': 'InternalServerError',
+
'message': 'Database error',
+
}),
+
data: {
+
'community': 'did:plc:community1',
+
'title': 'Test',
+
},
+
);
+
+
expect(
+
() => apiService.createPost(
+
community: 'did:plc:community1',
+
title: 'Test',
+
),
+
throwsA(isA<ServerException>()),
+
);
+
});
+
+
test('should handle network timeout', () async {
+
dioAdapter.onPost(
+
'/xrpc/social.coves.community.post.create',
+
(server) => server.throws(
+
408,
+
DioException.connectionTimeout(
+
timeout: const Duration(seconds: 30),
+
requestOptions: RequestOptions(),
+
),
+
),
+
data: {
+
'community': 'did:plc:community1',
+
'title': 'Test',
+
},
+
);
+
+
expect(
+
() => apiService.createPost(
+
community: 'did:plc:community1',
+
title: 'Test',
+
),
+
throwsA(isA<NetworkException>()),
+
);
+
});
+
});
+
}
+51 -44
lib/screens/home/post_detail_screen.dart
···
/// - Loading, empty, and error states
/// - Automatic comment loading on screen init
class PostDetailScreen extends StatefulWidget {
-
const PostDetailScreen({required this.post, this.isOptimistic = false, super.key});
+
const PostDetailScreen({
+
required this.post,
+
this.isOptimistic = false,
+
super.key,
+
});
/// Post to display (passed via route extras)
final FeedViewPost post;
···
}
class _PostDetailScreenState extends State<PostDetailScreen> {
-
final ScrollController _scrollController = ScrollController();
+
// ScrollController created lazily with cached scroll position for instant restoration
+
late ScrollController _scrollController;
final GlobalKey _commentsHeaderKey = GlobalKey();
// Cached provider from CommentsProviderCache
···
@override
void initState() {
super.initState();
-
_scrollController.addListener(_onScroll);
+
// ScrollController and provider initialization moved to didChangeDependencies
+
// where we have access to context for synchronous provider acquisition
+
}
-
// Initialize provider after frame is built
-
WidgetsBinding.instance.addPostFrameCallback((_) {
-
if (mounted) {
-
_initializeProvider();
-
_setupAuthListener();
-
}
-
});
+
@override
+
void didChangeDependencies() {
+
super.didChangeDependencies();
+
// Initialize provider synchronously on first call (has context access)
+
// This ensures cached data is available for the first build, avoiding
+
// the flash from loading state โ†’ content โ†’ scroll position jump
+
if (!_isInitialized) {
+
_initializeProviderSync();
+
}
}
/// Listen for auth state changes to handle sign-out
···
// If user signed out while viewing this screen, navigate back
// The CommentsProviderCache has already disposed our provider
-
if (!authProvider.isAuthenticated && _isInitialized && !_providerInvalidated) {
+
if (!authProvider.isAuthenticated &&
+
_isInitialized &&
+
!_providerInvalidated) {
_providerInvalidated = true;
if (kDebugMode) {
···
}
}
-
/// Initialize provider from cache and restore state
-
void _initializeProvider() {
+
/// Initialize provider synchronously from cache
+
///
+
/// Called from didChangeDependencies to ensure cached data is available
+
/// for the first build. Creates ScrollController with initialScrollOffset
+
/// set to cached position for instant scroll restoration without flicker.
+
void _initializeProviderSync() {
// Get or create provider from cache
final cache = context.read<CommentsProviderCache>();
_commentsCache = cache;
···
postCid: widget.post.post.cid,
);
+
// Create scroll controller with cached position for instant restoration
+
// This avoids the flash: loading โ†’ content at top โ†’ jump to cached position
+
final cachedScrollPosition = _commentsProvider.scrollPosition;
+
_scrollController = ScrollController(
+
initialScrollOffset: cachedScrollPosition,
+
);
+
_scrollController.addListener(_onScroll);
+
+
if (kDebugMode && cachedScrollPosition > 0) {
+
debugPrint(
+
'๐Ÿ“ Created ScrollController with initial offset: $cachedScrollPosition',
+
);
+
}
+
// Listen for changes to trigger rebuilds
_commentsProvider.addListener(_onProviderChanged);
+
// Setup auth listener
+
_setupAuthListener();
+
+
// Mark as initialized before triggering any loads
+
// This ensures the first build shows content (not loading) when cached
+
_isInitialized = true;
+
// Skip loading for optimistic posts (just created, not yet indexed)
if (widget.isOptimistic) {
if (kDebugMode) {
···
}
// Don't load comments - there won't be any yet
} else if (_commentsProvider.comments.isNotEmpty) {
-
// Already have data - restore scroll position immediately
+
// Already have cached data - it will render immediately
if (kDebugMode) {
debugPrint(
'๐Ÿ“ฆ Using cached comments (${_commentsProvider.comments.length})',
);
}
-
_restoreScrollPosition();
-
// Background refresh if data is stale
+
// Background refresh if data is stale (won't cause flicker)
if (_commentsProvider.isStale) {
if (kDebugMode) {
debugPrint('๐Ÿ”„ Data stale, refreshing in background');
···
// No cached data - load fresh
_commentsProvider.loadComments(refresh: true);
}
-
-
setState(() {
-
_isInitialized = true;
-
});
}
@override
···
}
}
-
/// Restore scroll position from provider
-
void _restoreScrollPosition() {
-
final savedPosition = _commentsProvider.scrollPosition;
-
if (savedPosition <= 0) {
-
return;
-
}
-
-
WidgetsBinding.instance.addPostFrameCallback((_) {
-
if (!mounted || !_scrollController.hasClients) {
-
return;
-
}
-
-
final maxExtent = _scrollController.position.maxScrollExtent;
-
final targetPosition = savedPosition.clamp(0.0, maxExtent);
-
-
if (targetPosition > 0) {
-
_scrollController.jumpTo(targetPosition);
-
if (kDebugMode) {
-
debugPrint('๐Ÿ“ Restored scroll to $targetPosition (max: $maxExtent)');
-
}
-
}
-
});
-
}
-
/// Handle sort changes from dropdown
Future<void> _onSortChanged(String newSort) async {
final success = await _commentsProvider.setSortOption(newSort);
-335
lib/providers/feed_provider.dart
···
-
import 'dart:async';
-
-
import 'package:flutter/foundation.dart';
-
import '../models/post.dart';
-
import '../services/coves_api_service.dart';
-
import 'auth_provider.dart';
-
import 'vote_provider.dart';
-
-
/// Feed types available in the app
-
enum FeedType {
-
/// All posts across the network
-
discover,
-
-
/// Posts from subscribed communities (authenticated only)
-
forYou,
-
}
-
-
/// Feed Provider
-
///
-
/// Manages feed state and fetching logic.
-
/// Supports both authenticated timeline and public discover feed.
-
///
-
/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
-
/// tokens before each authenticated request (critical for atProto OAuth
-
/// token rotation).
-
class FeedProvider with ChangeNotifier {
-
FeedProvider(
-
this._authProvider, {
-
CovesApiService? apiService,
-
VoteProvider? voteProvider,
-
}) : _voteProvider = voteProvider {
-
// Use injected service (for testing) or create new one (for production)
-
// Pass token getter, refresh handler, and sign out handler to API service
-
// for automatic fresh token retrieval and automatic token refresh on 401
-
_apiService =
-
apiService ??
-
CovesApiService(
-
tokenGetter: _authProvider.getAccessToken,
-
tokenRefresher: _authProvider.refreshToken,
-
signOutHandler: _authProvider.signOut,
-
);
-
-
// Track initial auth state
-
_wasAuthenticated = _authProvider.isAuthenticated;
-
-
// [P0 FIX] Listen to auth state changes and clear feed on sign-out
-
// This prevents privacy bug where logged-out users see their private
-
// timeline until they manually refresh.
-
_authProvider.addListener(_onAuthChanged);
-
}
-
-
/// Handle authentication state changes
-
///
-
/// Only clears and reloads feed when transitioning from authenticated
-
/// to unauthenticated (actual sign-out), not when staying unauthenticated
-
/// (e.g., failed sign-in attempt). This prevents unnecessary API calls.
-
void _onAuthChanged() {
-
final isAuthenticated = _authProvider.isAuthenticated;
-
-
// Only reload if transitioning from authenticated โ†’ unauthenticated
-
if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) {
-
if (kDebugMode) {
-
debugPrint('๐Ÿ”’ User signed out - clearing feed');
-
}
-
// Reset feed type to Discover since For You requires auth
-
_feedType = FeedType.discover;
-
reset();
-
// Automatically load the public discover feed
-
loadFeed(refresh: true);
-
}
-
-
// Update tracked state
-
_wasAuthenticated = isAuthenticated;
-
}
-
-
final AuthProvider _authProvider;
-
late final CovesApiService _apiService;
-
final VoteProvider? _voteProvider;
-
-
// Track previous auth state to detect transitions
-
bool _wasAuthenticated = false;
-
-
// Feed state
-
List<FeedViewPost> _posts = [];
-
bool _isLoading = false;
-
bool _isLoadingMore = false;
-
String? _error;
-
String? _cursor;
-
bool _hasMore = true;
-
-
// Feed configuration
-
String _sort = 'hot';
-
String? _timeframe;
-
FeedType _feedType = FeedType.discover;
-
-
// Time update mechanism for periodic UI refreshes
-
Timer? _timeUpdateTimer;
-
DateTime? _currentTime;
-
-
// Getters
-
List<FeedViewPost> get posts => _posts;
-
bool get isLoading => _isLoading;
-
bool get isLoadingMore => _isLoadingMore;
-
String? get error => _error;
-
bool get hasMore => _hasMore;
-
String get sort => _sort;
-
String? get timeframe => _timeframe;
-
DateTime? get currentTime => _currentTime;
-
FeedType get feedType => _feedType;
-
-
/// Check if For You feed is available (requires authentication)
-
bool get isForYouAvailable => _authProvider.isAuthenticated;
-
-
/// Start periodic time updates for "time ago" strings
-
///
-
/// Updates currentTime every minute to trigger UI rebuilds for
-
/// post timestamps. This ensures "5m ago" updates to "6m ago" without
-
/// requiring user interaction.
-
void startTimeUpdates() {
-
// Cancel existing timer if any
-
_timeUpdateTimer?.cancel();
-
-
// Update current time immediately
-
_currentTime = DateTime.now();
-
notifyListeners();
-
-
// Set up periodic updates (every minute)
-
_timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
-
_currentTime = DateTime.now();
-
notifyListeners();
-
});
-
-
if (kDebugMode) {
-
debugPrint('โฐ Started periodic time updates for feed timestamps');
-
}
-
}
-
-
/// Stop periodic time updates
-
void stopTimeUpdates() {
-
_timeUpdateTimer?.cancel();
-
_timeUpdateTimer = null;
-
_currentTime = null;
-
-
if (kDebugMode) {
-
debugPrint('โฐ Stopped periodic time updates');
-
}
-
}
-
-
/// Load feed based on current feed type
-
///
-
/// This method encapsulates the business logic of deciding which feed
-
/// to fetch based on the selected feed type.
-
Future<void> loadFeed({bool refresh = false}) async {
-
// For You requires authentication - fall back to Discover if not
-
if (_feedType == FeedType.forYou && _authProvider.isAuthenticated) {
-
await fetchTimeline(refresh: refresh);
-
} else {
-
await fetchDiscover(refresh: refresh);
-
}
-
-
// Start time updates when feed is loaded
-
if (_posts.isNotEmpty && _timeUpdateTimer == null) {
-
startTimeUpdates();
-
}
-
}
-
-
/// Switch feed type and reload
-
Future<void> setFeedType(FeedType type) async {
-
if (_feedType == type) {
-
return;
-
}
-
-
// For You requires authentication
-
if (type == FeedType.forYou && !_authProvider.isAuthenticated) {
-
return;
-
}
-
-
_feedType = type;
-
// Reset pagination state but keep posts visible until new feed loads
-
_cursor = null;
-
_hasMore = true;
-
_error = null;
-
notifyListeners();
-
-
// Load new feed - old posts stay visible until new ones arrive
-
await loadFeed(refresh: true);
-
}
-
-
/// Common feed fetching logic (DRY principle - eliminates code
-
/// duplication)
-
Future<void> _fetchFeed({
-
required bool refresh,
-
required Future<TimelineResponse> Function() fetcher,
-
required String feedName,
-
}) async {
-
if (_isLoading || _isLoadingMore) {
-
return;
-
}
-
-
try {
-
if (refresh) {
-
_isLoading = true;
-
// DON'T clear _posts, _cursor, or _hasMore yet
-
// Keep existing data visible until refresh succeeds
-
// This prevents transient failures from wiping the user's feed
-
// and pagination state
-
_error = null;
-
} else {
-
_isLoadingMore = true;
-
}
-
notifyListeners();
-
-
final response = await fetcher();
-
-
// Only update state after successful fetch
-
if (refresh) {
-
_posts = response.feed;
-
} else {
-
// Create new list instance to trigger context.select rebuilds
-
// Using spread operator instead of addAll to ensure reference changes
-
_posts = [..._posts, ...response.feed];
-
}
-
-
_cursor = response.cursor;
-
_hasMore = response.cursor != null;
-
_error = null;
-
-
if (kDebugMode) {
-
debugPrint('โœ… $feedName loaded: ${_posts.length} posts total');
-
}
-
-
// Initialize vote state from viewer data in feed response
-
// IMPORTANT: Call setInitialVoteState for ALL feed items, even when
-
// viewer.vote is null. This ensures that if a user removed their vote
-
// on another device, the local state is cleared on refresh.
-
if (_authProvider.isAuthenticated && _voteProvider != null) {
-
for (final feedItem in response.feed) {
-
final viewer = feedItem.post.viewer;
-
_voteProvider.setInitialVoteState(
-
postUri: feedItem.post.uri,
-
voteDirection: viewer?.vote,
-
voteUri: viewer?.voteUri,
-
);
-
}
-
}
-
} on Exception catch (e) {
-
_error = e.toString();
-
if (kDebugMode) {
-
debugPrint('โŒ Failed to fetch $feedName: $e');
-
}
-
} finally {
-
_isLoading = false;
-
_isLoadingMore = false;
-
notifyListeners();
-
}
-
}
-
-
/// Fetch timeline feed (authenticated)
-
///
-
/// Fetches the user's personalized timeline.
-
/// Authentication is handled automatically via tokenGetter.
-
Future<void> fetchTimeline({bool refresh = false}) => _fetchFeed(
-
refresh: refresh,
-
fetcher:
-
() => _apiService.getTimeline(
-
sort: _sort,
-
timeframe: _timeframe,
-
cursor: refresh ? null : _cursor,
-
),
-
feedName: 'Timeline',
-
);
-
-
/// Fetch discover feed (public)
-
///
-
/// Fetches the public discover feed.
-
/// Does not require authentication.
-
Future<void> fetchDiscover({bool refresh = false}) => _fetchFeed(
-
refresh: refresh,
-
fetcher:
-
() => _apiService.getDiscover(
-
sort: _sort,
-
timeframe: _timeframe,
-
cursor: refresh ? null : _cursor,
-
),
-
feedName: 'Discover',
-
);
-
-
/// Load more posts (pagination)
-
Future<void> loadMore() async {
-
if (!_hasMore || _isLoadingMore) {
-
return;
-
}
-
await loadFeed();
-
}
-
-
/// Change sort order
-
void setSort(String newSort, {String? newTimeframe}) {
-
_sort = newSort;
-
_timeframe = newTimeframe;
-
notifyListeners();
-
}
-
-
/// Retry loading after error
-
Future<void> retry() async {
-
_error = null;
-
await loadFeed(refresh: true);
-
}
-
-
/// Clear error
-
void clearError() {
-
_error = null;
-
notifyListeners();
-
}
-
-
/// Reset feed state
-
void reset() {
-
_posts = [];
-
_cursor = null;
-
_hasMore = true;
-
_error = null;
-
_isLoading = false;
-
_isLoadingMore = false;
-
notifyListeners();
-
}
-
-
@override
-
void dispose() {
-
// Stop time updates and cancel timer
-
stopTimeUpdates();
-
// Remove auth listener to prevent memory leaks
-
_authProvider.removeListener(_onAuthChanged);
-
_apiService.dispose();
-
super.dispose();
-
}
-
}
+4 -2
test/widget_test.dart
···
import 'package:coves_flutter/main.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
+
import 'package:coves_flutter/providers/multi_feed_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
-
ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)),
+
ChangeNotifierProvider(
+
create: (_) => MultiFeedProvider(authProvider),
+
),
],
child: const CovesApp(),
),
+4 -4
pubspec.lock
···
dependency: transitive
description:
name: meta
-
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
-
version: "1.17.0"
+
version: "1.16.0"
mime:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
-
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
+
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
-
version: "0.7.7"
+
version: "0.7.6"
typed_data:
dependency: transitive
description:
+9 -7
lib/screens/home/feed_screen.dart
···
/// Switch to a feed type and animate PageView
void _switchToFeedType(FeedType type, int pageIndex) {
-
final provider = context.read<MultiFeedProvider>();
-
provider.setCurrentFeed(type);
+
context.read<MultiFeedProvider>().setCurrentFeed(type);
// Animate to the corresponding page
_pageController.animateToPage(
···
void _restoreScrollPosition(FeedType type) {
// Wait for the next frame to ensure the controller has clients
WidgetsBinding.instance.addPostFrameCallback((_) {
-
if (!mounted) return;
+
if (!mounted) {
+
return;
+
}
final controller = _scrollControllers[type];
if (controller != null && controller.hasClients) {
···
scrollController: _getOrCreateScrollController(feedType),
onRefresh: () => provider.loadFeed(feedType, refresh: true),
onRetry: () => provider.retry(feedType),
-
onClearErrorAndLoadMore: () {
-
provider.clearError(feedType);
-
provider.loadMore(feedType);
-
},
+
onClearErrorAndLoadMore:
+
() =>
+
provider
+
..clearError(feedType)
+
..loadMore(feedType),
isAuthenticated: isAuthenticated,
currentTime: provider.currentTime,
);
+3 -2
test/widgets/feed_screen_test.dart
···
tester,
) async {
fakeAuthProvider.setAuthenticated(value: true);
-
fakeFeedProvider.setPosts(FeedType.discover, [_createMockPost('Post 1')]);
-
fakeFeedProvider.setPosts(FeedType.forYou, [_createMockPost('Post 2')]);
+
fakeFeedProvider
+
..setPosts(FeedType.discover, [_createMockPost('Post 1')])
+
..setPosts(FeedType.forYou, [_createMockPost('Post 2')]);
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
+5
.claude/settings.json
···
+
{
+
"enabledPlugins": {
+
"pr-review-toolkit@claude-plugins-official": true
+
}
+
}
+48
assets/icons/atproto/providers_landing.svg
···
+
<svg width="2266" height="825" viewBox="0 0 2266 825" fill="none" xmlns="http://www.w3.org/2000/svg">
+
<path d="M900 412.5C900 639.765 698.528 824 450 824C201.472 824 0 639.765 0 412.5C0 185.235 201.472 1 450 1C698.528 1 900 185.235 900 412.5Z" fill="#D9D9D9"/>
+
<path d="M2266 411.5C2266 638.765 2064.53 823 1816 823C1567.47 823 1366 638.765 1366 411.5C1366 184.235 1567.47 0 1816 0C2064.53 0 2266 184.235 2266 411.5Z" fill="#D9D9D9"/>
+
<path d="M2266 411.5C2266 638.765 2064.53 823 1816 823C1567.47 823 1366 638.765 1366 411.5C1366 184.235 1567.47 0 1816 0C2064.53 0 2266 184.235 2266 411.5Z" fill="url(#paint0_linear_41_51)"/>
+
<path d="M2266 411.5C2266 638.765 2064.53 823 1816 823C1567.47 823 1366 638.765 1366 411.5C1366 184.235 1567.47 0 1816 0C2064.53 0 2266 184.235 2266 411.5Z" fill="url(#paint1_linear_41_51)"/>
+
<path d="M1946.84 657.771H1828.59L1946.83 657.765V541.604L1946.84 657.771ZM1828.59 457.695C1828.59 488.549 1853.62 513.559 1884.49 513.559H1946.83V541.604H1884.49C1853.62 541.604 1828.59 566.615 1828.59 597.469V657.771L1802.53 657.765V597.469C1802.53 566.615 1777.5 541.605 1746.63 541.604H1684.29V513.559H1746.62C1777.5 513.559 1802.53 488.544 1802.53 457.688V395.392H1828.59V457.695Z" fill="black"/>
+
<path d="M1873.52 228.76C1851.69 250.579 1851.69 285.953 1873.52 307.771L1917.6 351.824L1897.76 371.653L1853.68 327.599C1831.85 305.781 1796.45 305.781 1774.62 327.599L1731.95 370.236L1713.53 351.823L1756.19 309.186C1778.03 287.368 1778.03 251.994 1756.19 230.176L1712.11 186.124L1731.95 166.295L1776.04 210.349C1797.87 232.167 1833.26 232.167 1855.1 210.349L1899.18 166.295L1917.6 184.707L1873.52 228.76Z" fill="black"/>
+
<path d="M1686.71 317.031C1678.72 346.835 1696.41 377.47 1726.24 385.456L1786.45 401.581L1779.19 428.665L1718.98 412.541C1689.15 404.555 1658.5 422.242 1650.51 452.046L1634.89 510.291L1609.72 503.551L1625.34 445.309C1633.33 415.505 1615.63 384.869 1585.81 376.884L1525.59 360.759L1532.85 333.672L1593.07 349.797C1622.89 357.784 1653.55 340.096 1661.54 310.292L1677.67 250.114L1702.84 256.853L1686.71 317.031Z" fill="black"/>
+
<path d="M1585 413.5C1585 640.765 1383.53 825 1135 825C886.472 825 685 640.765 685 413.5C685 186.235 886.472 2 1135 2C1383.53 2 1585 186.235 1585 413.5Z" fill="#D9D9D9"/>
+
<path d="M1585 413.5C1585 640.765 1383.53 825 1135 825C886.472 825 685 640.765 685 413.5C685 186.235 886.472 2 1135 2C1383.53 2 1585 186.235 1585 413.5Z" fill="url(#paint2_linear_41_51)"/>
+
<path d="M1585 413.5C1585 640.765 1383.53 825 1135 825C886.472 825 685 640.765 685 413.5C685 186.235 886.472 2 1135 2C1383.53 2 1585 186.235 1585 413.5Z" fill="url(#paint3_linear_41_51)"/>
+
<path d="M1227.39 691.928C1208.53 691.774 1194.14 686.291 1178.53 676.76C1156.15 664.983 1139.05 645.197 1126.84 623.382C1107.43 647.469 1081.49 662.084 1052.45 670.341C1040.08 673.933 1018.42 677.577 982.521 664.567C930.781 647.182 893.093 593.331 897.376 538.461C896.593 515.716 904.883 493.392 916.635 474.202C885.285 457.37 859.722 429.117 849.933 394.468C843.984 375.5 844.234 355.066 846.428 335.567C854.284 289.529 888.808 249.496 933.253 235.097C950.993 194.658 989.706 164.502 1033.57 158.362C1062.69 154.306 1092.83 160.404 1118.31 175.209C1155.43 134.029 1220.1 121.859 1269.48 147.155C1307.15 165.149 1334.08 202.689 1340.62 243.624C1376.46 257.987 1406.65 287.278 1418.5 324.461C1426.42 347.482 1426.68 372.84 1421.55 396.478C1412.39 433.363 1386.36 464.764 1352.66 481.926C1352.75 488.49 1374.32 535.818 1370.71 571.536C1369.92 616.189 1341.61 658.508 1302.34 679.064C1279.43 692.442 1252.27 692.19 1227.39 691.928ZM1120.02 563.391C1151.78 559.853 1172.6 532.155 1188.77 507.209C1196.41 495.846 1202.25 483.126 1208.06 471.03C1215.58 477.929 1221.96 490.926 1233.86 494.021C1246.39 497.926 1261.09 494.754 1268.76 483.364C1283.45 455.963 1276.21 422.901 1267.66 394.507C1262.39 378.198 1255.48 361.467 1242.33 349.908C1245.14 330.122 1233.42 310.028 1216.75 299.672C1202.55 310.998 1180.95 310.93 1167.25 298.814C1141 325.598 1116.94 324.709 1093.7 303.482C1088.47 298.711 1078.51 332.601 1043.53 313.403C1023.44 330.244 1007.86 346.445 994.052 369.771C980.637 394.919 966.588 417.253 965.377 444.562C964.795 460.523 977.264 477.246 994.158 475.948C1011.04 477.456 1022.54 460.833 1035.32 453.928C1037.23 476.204 1039.38 500.136 1046.9 521.883C1055.54 550.024 1085.97 567.91 1114.76 563.814C1116.8 563.655 1120.02 563.389 1120.02 563.391ZM1136.5 479.359C1121.05 469.889 1128.49 449.336 1127.87 434.408C1129.41 416.394 1130.64 397.454 1138.74 381.042C1147.3 369.341 1168.2 373.855 1169.12 388.868C1168.51 403.968 1161.58 419 1162.41 434.654C1160.61 447.726 1163.71 462.408 1157.93 474.355C1153.18 480.965 1143.52 482.887 1136.5 479.359ZM1069.04 470.755C1054.49 462.859 1059.11 442.989 1056.83 429.175C1058.72 413.181 1057.14 392.893 1070.53 381.677C1083.62 372.545 1101.44 388.184 1095.25 402.541C1088.65 420.686 1092.97 440.511 1093.11 458.909C1090.62 469.762 1079.01 475.523 1069.04 470.755Z" fill="black"/>
+
<path d="M1970.01 309.102C1978.01 338.906 2008.66 356.594 2038.48 348.608L2098.7 332.483L2105.96 359.567L2045.74 375.692C2015.92 383.678 1998.22 414.313 2006.21 444.117L2021.83 502.362L1996.66 509.101L1981.05 450.858C1973.06 421.054 1942.4 403.367 1912.58 411.352L1852.36 427.477L1845.1 400.392L1905.32 384.267C1935.14 376.28 1952.84 345.646 1944.85 315.841L1928.71 255.663L1953.88 248.925L1970.01 309.102Z" fill="black"/>
+
<path d="M900 412.5C900 639.765 698.528 824 450 824C201.472 824 0 639.765 0 412.5C0 185.235 201.472 1 450 1C698.528 1 900 185.235 900 412.5Z" fill="url(#paint4_linear_41_51)"/>
+
<path d="M900 412.5C900 639.765 698.528 824 450 824C201.472 824 0 639.765 0 412.5C0 185.235 201.472 1 450 1C698.528 1 900 185.235 900 412.5Z" fill="url(#paint5_linear_41_51)"/>
+
<path d="M900 412.5C900 639.765 698.528 824 450 824C201.472 824 0 639.765 0 412.5C0 185.235 201.472 1 450 1C698.528 1 900 185.235 900 412.5Z" fill="url(#paint6_linear_41_51)"/>
+
<path d="M285.723 191.375C352.219 241.296 423.743 342.515 450.003 396.835C476.265 342.519 547.785 241.295 614.283 191.375C662.263 155.354 740.003 127.483 740.003 216.17C740.003 233.882 729.848 364.96 723.892 386.24C703.189 460.224 627.748 479.094 560.642 467.673C677.942 487.637 707.782 553.765 643.339 619.893C520.949 745.483 467.429 588.382 453.709 548.127C451.195 540.747 450.019 537.295 450.001 540.231C449.984 537.295 448.808 540.747 446.294 548.127C432.58 588.382 379.061 745.487 256.664 619.893C192.22 553.765 222.059 487.633 339.361 467.673C272.253 479.094 196.811 460.223 176.111 386.24C170.153 364.958 160 233.88 160 216.17C160 127.483 237.742 155.354 285.72 191.375H285.723Z" fill="#1185FE"/>
+
<defs>
+
<linearGradient id="paint0_linear_41_51" x1="1133" y1="0" x2="1133" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint1_linear_41_51" x1="1133" y1="0" x2="1133" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint2_linear_41_51" x1="1133" y1="0" x2="1133" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint3_linear_41_51" x1="1133" y1="0" x2="1133" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint4_linear_41_51" x1="1136.96" y1="825" x2="1135.7" y2="-0.0100808" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint5_linear_41_51" x1="1136.96" y1="825" x2="1135.7" y2="-0.0100808" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint6_linear_41_51" x1="1136.96" y1="825" x2="1135.7" y2="-0.0100808" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
</defs>
+
</svg>
+132
assets/icons/atproto/providers_stack.svg
···
+
<svg width="2947" height="825" viewBox="0 0 2947 825" fill="none" xmlns="http://www.w3.org/2000/svg">
+
<path d="M1581 412.5C1581 639.765 1379.53 824 1131 824C882.472 824 681 639.765 681 412.5C681 185.235 882.472 1 1131 1C1379.53 1 1581 185.235 1581 412.5Z" fill="#D9D9D9"/>
+
<path d="M2947 411.5C2947 638.765 2745.53 823 2497 823C2248.47 823 2047 638.765 2047 411.5C2047 184.235 2248.47 0 2497 0C2745.53 0 2947 184.235 2947 411.5Z" fill="#D9D9D9"/>
+
<path d="M2947 411.5C2947 638.765 2745.53 823 2497 823C2248.47 823 2047 638.765 2047 411.5C2047 184.235 2248.47 0 2497 0C2745.53 0 2947 184.235 2947 411.5Z" fill="url(#paint0_linear_43_123)"/>
+
<path d="M2947 411.5C2947 638.765 2745.53 823 2497 823C2248.47 823 2047 638.765 2047 411.5C2047 184.235 2248.47 0 2497 0C2745.53 0 2947 184.235 2947 411.5Z" fill="url(#paint1_linear_43_123)"/>
+
<path d="M2627.84 657.771H2509.59L2627.83 657.765V541.604L2627.84 657.771ZM2509.59 457.695C2509.59 488.549 2534.62 513.559 2565.49 513.559H2627.83V541.604H2565.49C2534.62 541.604 2509.59 566.615 2509.59 597.469V657.771L2483.53 657.765V597.469C2483.53 566.615 2458.5 541.605 2427.63 541.604H2365.29V513.559H2427.62C2458.5 513.559 2483.53 488.544 2483.53 457.688V395.392H2509.59V457.695Z" fill="black"/>
+
<path d="M2554.52 228.76C2532.69 250.578 2532.69 285.953 2554.52 307.77L2598.6 351.824L2578.76 371.652L2534.68 327.599C2512.85 305.78 2477.45 305.78 2455.62 327.599L2412.95 370.236L2394.53 351.823L2437.19 309.186C2459.03 287.367 2459.03 251.994 2437.19 230.175L2393.11 186.123L2412.95 166.295L2457.04 210.348C2478.87 232.167 2514.27 232.167 2536.1 210.348L2580.18 166.295L2598.6 184.706L2554.52 228.76Z" fill="black"/>
+
<path d="M2367.71 317.031C2359.72 346.835 2377.41 377.471 2407.24 385.456L2467.45 401.581L2460.19 428.666L2399.98 412.541C2370.15 404.555 2339.5 422.242 2331.51 452.046L2315.89 510.291L2290.72 503.551L2306.34 445.309C2314.33 415.505 2296.63 384.87 2266.81 376.884L2206.59 360.759L2213.85 333.673L2274.07 349.798C2303.89 357.784 2334.55 340.096 2342.54 310.292L2358.67 250.114L2383.84 256.853L2367.71 317.031Z" fill="black"/>
+
<path d="M2266 413.5C2266 640.765 2064.53 825 1816 825C1567.47 825 1366 640.765 1366 413.5C1366 186.235 1567.47 2 1816 2C2064.53 2 2266 186.235 2266 413.5Z" fill="#D9D9D9"/>
+
<path d="M2266 413.5C2266 640.765 2064.53 825 1816 825C1567.47 825 1366 640.765 1366 413.5C1366 186.235 1567.47 2 1816 2C2064.53 2 2266 186.235 2266 413.5Z" fill="url(#paint2_linear_43_123)"/>
+
<path d="M2266 413.5C2266 640.765 2064.53 825 1816 825C1567.47 825 1366 640.765 1366 413.5C1366 186.235 1567.47 2 1816 2C2064.53 2 2266 186.235 2266 413.5Z" fill="url(#paint3_linear_43_123)"/>
+
<path d="M1908.39 691.928C1889.53 691.774 1875.14 686.291 1859.53 676.76C1837.15 664.983 1820.05 645.197 1807.84 623.382C1788.43 647.469 1762.49 662.084 1733.45 670.341C1721.08 673.933 1699.42 677.577 1663.52 664.567C1611.78 647.182 1574.09 593.331 1578.38 538.461C1577.59 515.716 1585.88 493.392 1597.64 474.202C1566.29 457.37 1540.72 429.117 1530.93 394.468C1524.98 375.5 1525.23 355.066 1527.43 335.567C1535.28 289.529 1569.81 249.496 1614.25 235.097C1631.99 194.658 1670.71 164.502 1714.57 158.362C1743.69 154.306 1773.83 160.404 1799.31 175.209C1836.43 134.029 1901.1 121.859 1950.48 147.155C1988.15 165.149 2015.08 202.689 2021.62 243.624C2057.46 257.987 2087.65 287.278 2099.5 324.461C2107.42 347.482 2107.68 372.84 2102.55 396.478C2093.39 433.363 2067.36 464.764 2033.66 481.926C2033.75 488.49 2055.32 535.818 2051.71 571.536C2050.92 616.189 2022.61 658.508 1983.34 679.064C1960.43 692.442 1933.27 692.19 1908.39 691.928ZM1801.02 563.391C1832.78 559.853 1853.6 532.155 1869.77 507.209C1877.41 495.846 1883.25 483.126 1889.06 471.03C1896.58 477.929 1902.96 490.926 1914.86 494.021C1927.39 497.926 1942.09 494.754 1949.76 483.364C1964.45 455.963 1957.21 422.901 1948.66 394.507C1943.39 378.198 1936.48 361.467 1923.33 349.908C1926.14 330.122 1914.42 310.028 1897.75 299.672C1883.55 310.998 1861.95 310.93 1848.25 298.814C1822 325.598 1797.94 324.709 1774.7 303.482C1769.47 298.711 1759.51 332.601 1724.53 313.403C1704.44 330.244 1688.86 346.445 1675.05 369.771C1661.64 394.919 1647.59 417.253 1646.38 444.562C1645.8 460.523 1658.26 477.246 1675.16 475.948C1692.04 477.456 1703.54 460.833 1716.32 453.928C1718.23 476.204 1720.38 500.136 1727.9 521.883C1736.54 550.024 1766.97 567.91 1795.76 563.814C1797.8 563.655 1801.02 563.389 1801.02 563.391ZM1817.5 479.359C1802.05 469.889 1809.49 449.336 1808.87 434.408C1810.41 416.394 1811.64 397.454 1819.74 381.042C1828.3 369.341 1849.2 373.855 1850.12 388.868C1849.51 403.968 1842.58 419 1843.41 434.654C1841.61 447.726 1844.71 462.408 1838.93 474.355C1834.18 480.965 1824.52 482.887 1817.5 479.359ZM1750.04 470.755C1735.49 462.859 1740.11 442.989 1737.83 429.175C1739.72 413.181 1738.14 392.893 1751.53 381.677C1764.62 372.545 1782.44 388.184 1776.25 402.541C1769.65 420.686 1773.97 440.511 1774.11 458.909C1771.62 469.762 1760.01 475.523 1750.04 470.755Z" fill="black"/>
+
<path d="M2651.01 309.103C2659.01 338.907 2689.66 356.594 2719.48 348.608L2779.7 332.483L2786.96 359.568L2726.74 375.692C2696.92 383.678 2679.22 414.313 2687.21 444.117L2702.83 502.362L2677.66 509.101L2662.05 450.858C2654.06 421.054 2623.4 403.367 2593.58 411.353L2533.36 427.477L2526.1 400.392L2586.32 384.267C2616.14 376.281 2633.84 345.646 2625.85 315.841L2609.71 255.664L2634.88 248.925L2651.01 309.103Z" fill="black"/>
+
<path d="M1581 412.5C1581 639.765 1379.53 824 1131 824C882.472 824 681 639.765 681 412.5C681 185.235 882.472 1 1131 1C1379.53 1 1581 185.235 1581 412.5Z" fill="url(#paint4_linear_43_123)"/>
+
<path d="M1581 412.5C1581 639.765 1379.53 824 1131 824C882.472 824 681 639.765 681 412.5C681 185.235 882.472 1 1131 1C1379.53 1 1581 185.235 1581 412.5Z" fill="url(#paint5_linear_43_123)"/>
+
<path d="M1581 412.5C1581 639.765 1379.53 824 1131 824C882.472 824 681 639.765 681 412.5C681 185.235 882.472 1 1131 1C1379.53 1 1581 185.235 1581 412.5Z" fill="url(#paint6_linear_43_123)"/>
+
<path d="M966.723 191.375C1033.22 241.296 1104.74 342.515 1131 396.835C1157.26 342.519 1228.78 241.295 1295.28 191.375C1343.26 155.354 1421 127.483 1421 216.17C1421 233.882 1410.85 364.96 1404.89 386.24C1384.19 460.224 1308.75 479.094 1241.64 467.673C1358.94 487.637 1388.78 553.765 1324.34 619.893C1201.95 745.483 1148.43 588.382 1134.71 548.127C1132.19 540.747 1131.02 537.295 1131 540.231C1130.98 537.295 1129.81 540.747 1127.29 548.127C1113.58 588.382 1060.06 745.487 937.664 619.893C873.22 553.765 903.059 487.633 1020.36 467.673C953.253 479.094 877.811 460.223 857.111 386.24C851.153 364.958 841 233.88 841 216.17C841 127.483 918.742 155.354 966.72 191.375H966.723Z" fill="#1185FE"/>
+
<path d="M900 413.5C900 640.765 698.528 825 450 825C201.472 825 0 640.765 0 413.5C0 186.235 201.472 2 450 2C698.528 2 900 186.235 900 413.5Z" fill="url(#paint7_linear_43_123)"/>
+
<path d="M486.784 149.972C495.12 150.218 503.341 152.013 511.019 155.268C524.964 161.428 535.885 172.888 541.369 187.118C547.013 201.61 546.676 217.752 540.425 231.995C532.095 250.772 520.436 258.342 502.204 265.37C494.867 288.819 488.558 312.417 485.651 336.856C484.994 342.414 484.337 348.605 483.188 354.046C516.692 363.194 543.972 380.336 568.387 404.647C572.953 398.282 578.281 395.46 585.137 392.085C586.485 382.618 590.368 375.181 596.029 367.542C591.872 360.952 588.254 355.397 585.399 348.038C569.255 306.213 584.476 252.122 621.186 225.959C631.745 217.883 642.978 216.272 638.728 233.862C634.423 251.704 636.31 265.635 641.904 282.819C646.077 279.339 653.372 273.415 656.43 269.228C665.527 256.765 665.397 239.306 664.474 224.575C664.293 220.613 662.836 216.033 664.39 212.212C668.248 202.722 680.576 212.228 685.297 215.947C726.387 248.307 734.343 309.139 711.145 354.42C699.074 377.974 686.199 389.819 661.379 398.236C654.042 418.948 642.906 430.422 622.753 435.435C622.171 435.578 616.637 439.917 615.369 440.78C628.304 453.123 608.365 471.489 601.947 482.664L601.656 483.178C609.052 480.714 618.533 477.137 626.299 476.607C629.786 476.371 631.117 477.255 633.531 479.484C638.913 480.212 646.157 480.718 651.06 482.921C653.894 484.197 657.234 489.341 659.235 491.965C676.794 514.977 686.91 543.318 690.57 571.865C690.659 575.652 689.265 579.321 688.747 583.024C688.452 585.163 688.292 587.388 688.098 589.54C686.708 604.827 682.817 620.088 675.652 633.715C673.626 637.57 668.724 645.994 663.472 642.254C662.566 641.609 661.606 640.506 661.45 639.381C659.686 632.313 659.816 624.907 659.062 617.696C658.148 609.616 656.969 601.533 655.954 593.47C655.516 593.226 655.091 592.969 654.674 592.699C640.025 583.066 632.217 546.17 628.868 529.35C618.853 534.295 613.15 536.805 602.591 540.82C612.539 549.236 615.976 547.994 618.857 562.471C622.652 581.537 622.171 598.782 618.714 617.788L614.388 619.3C605.712 633.193 597.52 642.755 582.977 651.087C577.476 654.044 570.274 658.231 563.784 657.069C561.362 656.635 559.762 652.839 560.705 650.523C562.895 645.143 565.717 640.03 568.29 634.806C572.047 627.005 575.623 619.115 579.009 611.145C575.484 600.008 578.369 590.277 580.488 578.879C556.455 576.01 543.879 571.305 522.766 559.944C513.453 562.632 506.609 565.058 496.897 567.379C480.736 570.547 464.281 572.008 447.813 571.73C438.994 571.663 429.9 570.968 421.135 571.018C412.877 571.065 404.523 571.511 396.203 571.566C380.459 571.756 364.721 570.833 349.108 568.803C354.483 588.07 357.307 613.765 332.703 589.237C325.165 581.722 311.451 577.27 301.211 574.523C301.625 576.267 302.083 578.007 302.585 579.73C307.893 597.548 314.567 605.408 331.638 612.679C336.396 614.709 348.184 618.273 349.608 623.273C348.559 634.857 317.425 644.267 307.345 646.306C278.31 652.182 253.074 645.139 228.686 629.17C230.525 634.36 238.063 649.221 236.403 654.503C233.978 662.216 222.014 655.165 217.645 652.393C204.579 644.107 196.386 633.412 189.559 619.776C181.384 615.589 183.781 598.18 184.445 590.416L178.56 593.407C178.372 595.581 177.939 598.175 177.569 600.336C175.547 612.114 175.176 624.06 173.729 635.889C173.396 638.349 172.418 643.277 169.173 643.37C160.859 643.597 155.703 627.67 153.454 621.845C148.363 608.656 146.643 595.105 145.511 581.191C145.333 579.005 144.292 576.869 144 574.7C146.569 543.107 158.914 512.024 178.648 487.222C184.497 479.867 191.193 480.671 199.818 479.922C200.552 479.193 201.75 477.992 202.695 477.681C211.381 474.812 229.13 482.689 237.224 485.773C232.308 474.231 213.982 457.866 219.206 444.374C222.007 437.141 236.454 437.065 242.39 432.233C251.565 424.765 258.883 415.148 266.904 406.488C288.845 382.818 314.421 366.418 345.06 356.525C341.968 340.168 338.76 324.072 333.624 308.201C330.904 299.798 327.446 291.262 324.482 282.865C312.306 280.948 302.359 277.576 292.662 269.555C280.76 259.723 273.361 245.477 272.161 230.085C270.859 214.725 275.444 199.109 285.546 187.366C295.62 175.693 309.91 168.488 325.285 167.329C340.607 166.199 355.743 171.25 367.317 181.354C378.836 191.625 385.861 206.009 386.879 221.409C387.997 240.828 380.654 254.093 368.211 267.984C369.362 275.096 370.229 282.024 371.572 289.141C375.338 308.445 380.361 327.483 386.61 346.132C402.163 343.299 425.714 343.765 441.436 345.125C444.275 331.271 447.91 317.909 450.488 303.802C453.389 287.941 455.049 274.116 456.544 258.147C453.966 256.457 452.021 255.217 449.649 253.194C437.958 243.196 430.713 228.966 429.509 213.629C426.973 178.12 452 152.499 486.784 149.972Z" fill="black"/>
+
<path d="M359.136 443.186C365.418 442.465 373.024 444.015 378.817 446.661C380.66 449.096 384.002 453.38 386.452 454.913C398.521 462.461 416.296 464.825 430.039 461.796C441.251 459.323 449.738 454.18 455.904 444.63C465.822 442.933 467.97 444.306 476.508 443.809C483.343 445.856 491.43 448.417 497.891 451.379C516.97 460.124 530.629 469.749 551.654 473.953C571.479 477.049 573.884 475.44 593.081 474.151C588.267 482.441 583.95 490.739 578.592 498.696C559.109 527.618 537.052 545.192 503.017 554.064C474.389 561.524 449.961 560.593 420.747 560.011C404.266 559.683 386.793 560.631 370.314 559.813C361.993 559.401 351.668 557.733 343.291 556.553C339.725 551.351 336.584 546.435 332.626 541.485C321.456 527.526 306.295 520.483 288.952 517.193C284.456 494.534 275.83 486.902 252.622 489.037C249.983 484.576 246.725 479.092 244.726 474.336C259.555 477.045 265.855 475.625 279.953 474.85C303.322 469.778 316.78 460.831 337.731 450.658C343.352 447.929 352.958 444.955 359.136 443.186Z" fill="#FBCA90"/>
+
<path d="M551.654 473.953C571.479 477.049 573.884 475.44 593.081 474.151C588.267 482.441 583.95 490.739 578.592 498.696C559.109 527.618 537.052 545.192 503.017 554.064C474.389 561.524 449.961 560.593 420.747 560.011C404.266 559.683 386.793 560.631 370.314 559.813C361.993 559.401 351.668 557.733 343.291 556.553C339.725 551.351 336.584 546.435 332.626 541.485C321.456 527.526 306.295 520.483 288.952 517.193C284.456 494.534 275.83 486.902 252.622 489.037C249.983 484.576 246.725 479.092 244.726 474.336C259.555 477.045 265.855 475.625 279.953 474.85C280.88 478.018 284.257 482.18 286.289 484.745C296.483 497.631 308.334 506.738 323.037 514.038C342.637 523.604 364.266 528.267 386.065 527.627C395.8 527.437 405.679 526.452 415.406 526.582C428.228 526.725 441.23 527.993 454.038 527.441C487.315 526.009 521.363 513.292 542.632 486.67C545.631 482.917 549.519 478.27 551.654 473.953Z" fill="#DAA66F"/>
+
<path d="M389.063 356.665C401.33 355.048 426.417 354.909 438.64 356.724C437.802 360.016 436.829 363.387 435.927 366.673C435.211 377.357 442.258 383.823 451.498 387.338C463.683 391.972 479.873 391.374 482.147 375.511L482.253 365.638C503.695 373.117 537.726 387.582 552.404 405.401L564.988 418.147C567.928 438.695 576.015 451.257 599.184 447.175C602.128 448.321 604.979 449.437 607.965 450.473C605.59 456.37 604.688 458.999 600.826 464.134C584.017 468.7 566.357 469.168 549.333 465.499C543.673 464.193 538.113 462.504 532.684 460.444C519.83 455.549 489.16 440.911 476.508 443.809C467.97 444.306 465.822 442.933 455.904 444.63C449.738 454.18 441.251 459.323 430.039 461.796C416.296 464.825 398.521 462.461 386.452 454.913C384.002 453.38 380.66 449.096 378.817 446.661C373.024 444.015 365.418 442.465 359.136 443.186C351.318 438.876 314.777 454.753 306.297 458.363C284.213 467.769 262.001 470.772 238.368 465.28C235.133 461.118 233.239 458.114 230.815 453.409C230.21 452.129 230.154 452.188 230.098 450.797C231.749 447.769 234.944 447.899 238.119 446.825C254.116 441.4 264.251 426.105 275.211 413.906C295.545 391.281 319.073 377.859 347.273 367.162C347.276 371.514 347.2 375.957 347.546 380.288C357.958 402.216 398.069 383.967 393.25 367.62C391.563 363.714 390.437 360.648 389.063 356.665Z" fill="#DF7E40"/>
+
<path d="M230.815 453.409C230.21 452.129 230.154 452.188 230.098 450.797C231.749 447.769 234.944 447.899 238.119 446.825C254.116 441.4 264.251 426.105 275.211 413.906C295.545 391.281 319.073 377.859 347.273 367.162C347.276 371.514 347.2 375.957 347.546 380.288C343.188 382.664 338.514 384.656 334.117 386.816C315.532 395.948 298.854 407.583 284.935 422.949C276.163 432.634 267.783 443.308 255.181 447.786C246.112 451.299 238.936 451.113 230.815 453.409Z" fill="#DAA66F"/>
+
<path d="M378.817 446.661C377.999 443.75 377.431 441.589 377.954 438.535C378.791 437.554 378.903 437.44 380.542 438.358C383.309 439.963 385.702 442.267 388.408 443.956C404.878 454.252 426.771 455.006 443.34 444.588C446.305 442.722 450.942 438.472 454.299 438.143C457.281 439.559 456.38 441.8 455.904 444.63C449.738 454.18 441.251 459.323 430.039 461.796C416.296 464.825 398.521 462.461 386.452 454.913C384.002 453.38 380.66 449.096 378.817 446.661Z" fill="black"/>
+
<path d="M389.063 356.665C401.33 355.048 426.417 354.909 438.64 356.724C437.802 360.016 436.829 363.387 435.927 366.673C420.926 366.655 408.299 366.762 393.25 367.62C391.563 363.714 390.437 360.648 389.063 356.665Z" fill="#DAA66F"/>
+
<path d="M482.253 365.638C503.695 373.117 537.726 387.582 552.404 405.401C548.461 405.957 539.44 398.429 535.645 396.403C520.335 388.235 499.138 378.027 482.147 375.511L482.253 365.638Z" fill="#DAA66F"/>
+
<path d="M302.546 418.16C313.039 417.848 311.009 425.304 305.25 430.443C303.06 432.394 301.209 433.076 298.58 434.196C282.94 434.205 293.179 421.572 302.546 418.16Z" fill="#DAA66F"/>
+
<path d="M348.688 403.21C352.118 402.637 355.37 404.933 355.976 408.362C356.581 411.787 354.315 415.06 350.896 415.7C347.429 416.349 344.1 414.04 343.486 410.569C342.872 407.094 345.209 403.787 348.688 403.21Z" fill="black"/>
+
<path d="M481.861 403.223C485.167 402.738 488.28 404.921 488.953 408.194C489.627 411.471 487.631 414.702 484.401 415.565C482.139 416.168 479.726 415.468 478.138 413.75C476.55 412.031 476.044 409.571 476.824 407.364C477.603 405.156 479.544 403.56 481.861 403.223Z" fill="black"/>
+
<path d="M320.504 406.513C323.825 405.708 327.172 407.739 327.999 411.054C328.826 414.369 326.823 417.73 323.514 418.581C320.171 419.441 316.768 417.414 315.933 414.061C315.098 410.713 317.149 407.326 320.504 406.513Z" fill="black"/>
+
<path d="M410.25 378.971C418.943 380.13 420.983 385.737 412.672 390.388C402.285 390.784 401.18 381.845 410.25 378.971Z" fill="#DAA66F"/>
+
<path d="M509.486 406.964C512.708 406.247 515.917 408.202 516.76 411.395C517.602 414.584 515.774 417.869 512.619 418.838C510.463 419.499 508.121 418.927 506.512 417.347C504.903 415.767 504.289 413.438 504.912 411.269C505.531 409.103 507.287 407.452 509.486 406.964Z" fill="black"/>
+
<path d="M674.907 223.502L674.806 222.742L675.391 222.371C682.863 224.376 696.1 242.405 699.899 249.075C713.625 272.738 716.515 301.107 709.822 327.51C702.422 356.713 680.096 389.322 646.831 389.583C636.209 389.667 626.594 382.904 621.611 373.799C621.346 373.304 621.089 372.802 620.845 372.294L620.36 371.086C609.397 348.637 622.896 316.403 639.41 300.245C656.586 283.439 667.932 278.611 673.98 253.865C675.846 243.368 675.842 234.08 674.907 223.502Z" fill="#EC7558"/>
+
<path d="M674.907 223.502L674.806 222.742L675.391 222.371C682.863 224.376 696.1 242.405 699.899 249.075C713.625 272.738 716.515 301.107 709.822 327.51C702.422 356.713 680.096 389.322 646.831 389.583C636.209 389.667 626.594 382.904 621.611 373.799C621.346 373.304 621.089 372.802 620.845 372.294C630.388 374.504 644.919 376.223 653.722 371.819C698.198 349.559 705.547 283.972 686.106 242.595C684.396 238.957 677.548 224.562 674.907 223.502Z" fill="#D25742"/>
+
<path d="M673.98 253.865C675.218 255.065 675.29 255.341 675.311 257.05C675.568 275.686 667.999 288.339 655.899 301.625C645.504 313.04 634.845 325.424 628.102 339.353C625.663 344.531 624.871 348.951 623.586 354.314C622.723 357.94 621.957 368.604 620.36 371.086C609.397 348.637 622.896 316.403 639.41 300.245C656.586 283.439 667.932 278.611 673.98 253.865Z" fill="#FCA78D"/>
+
<path d="M638.997 330.878C647.547 330.662 644.826 337.94 640.842 342.173C632.991 342.04 634.659 334.964 638.997 330.878Z" fill="#FCA78D"/>
+
<path d="M230.926 544.207C248.351 538.137 264.226 544.796 277.105 556.785C290.827 569.557 292.87 584.645 300.046 600.909C308.524 613.66 318.891 618.155 332.509 624.178L333.821 624.684L334.002 625.248C326.877 630.206 310.751 635.093 302.146 636.382C278.633 639.966 254.599 635.324 235.367 620.922C215.625 606.141 202.121 575.13 218.694 553.251C222.938 548.861 225.251 546.595 230.926 544.207Z" fill="#EC7558"/>
+
<path d="M218.694 553.251C219.18 558.836 219.304 565.622 220.102 570.875C222.189 584.607 232.221 599.616 242.955 608.277C263.707 625.025 289.295 629.697 315.276 626.765C318.359 626.415 330.422 624.545 332.306 624.583L332.509 624.178L333.821 624.684L334.002 625.248C326.877 630.206 310.751 635.093 302.146 636.382C278.633 639.966 254.599 635.324 235.367 620.922C215.625 606.141 202.121 575.13 218.694 553.251Z" fill="#D25742"/>
+
<path d="M230.926 544.207C248.351 538.137 264.226 544.796 277.105 556.785C290.827 569.557 292.87 584.645 300.046 600.909C294.339 600.77 283.239 578.028 279.481 573.061C276.728 569.426 274.126 565.121 270.415 561.802C262.505 554.771 252.881 549.308 242.554 546.835C239.147 546.018 233.955 545.849 230.926 544.207Z" fill="#FCA78D"/>
+
<path d="M259.89 560.37C265.721 560.765 269.614 564.325 264.783 569.721C259.381 568.803 255.082 565.761 259.89 560.37Z" fill="#FCA78D"/>
+
<path d="M442.056 197.202C446.785 183.2 453.432 173.442 467.061 166.663C478.02 161.298 490.651 160.46 502.229 164.329C514.216 168.46 524.054 177.21 529.559 188.634C531.909 193.554 532.768 197.227 533.792 202.518C534.52 207.326 534.087 213.746 533.076 218.504C530.38 230.662 523.001 241.267 512.535 248.013C502.111 254.721 488.836 256.799 476.803 254.088C464.677 251.288 454.173 243.755 447.628 233.166C440.99 222.504 439.217 209.359 442.056 197.202Z" fill="white"/>
+
<path d="M442.056 197.202C444.823 200.703 446.92 206.892 448.866 211.096C454.649 223.59 468.396 232.074 481.688 234.016C493.89 235.762 506.285 232.592 516.149 225.203C522.222 220.61 525.886 215.345 529.812 208.89C530.911 207.08 532.545 204.107 533.792 202.518C534.52 207.326 534.087 213.746 533.076 218.504C530.38 230.662 523.001 241.267 512.535 248.013C502.111 254.721 488.836 256.799 476.803 254.088C464.677 251.288 454.173 243.755 447.628 233.166C440.99 222.504 439.217 209.359 442.056 197.202Z" fill="#CBCBCB"/>
+
<path d="M471.121 188.328C474.448 188.138 477.493 187.934 480.77 188.657C497.895 192.405 499.028 216.562 484.868 224.783C479.115 228.123 473.067 227.557 466.989 225.854C465.283 225.083 464.15 224.53 462.643 223.384C458.734 220.43 456.211 215.997 455.668 211.126C455.024 205.06 457.348 198.624 461.185 193.943C461.986 197.105 463.102 201.814 466.724 202.878C469.878 203.806 474.76 200.849 475.337 197.591C475.83 194.833 472.641 191.213 470.982 189.168L471.121 188.328Z" fill="black"/>
+
<path d="M283.388 221.28C285.234 199.363 302.311 181.807 324.166 179.36C346.022 176.913 366.558 190.258 373.203 211.225C378.829 228.979 373.215 248.374 358.975 260.376C344.735 272.377 324.671 274.624 308.131 266.068C291.59 257.513 281.826 239.839 283.388 221.28Z" fill="white"/>
+
<path d="M373.203 211.225C378.829 228.979 373.215 248.374 358.975 260.376C344.735 272.377 324.671 274.624 308.131 266.068C291.59 257.513 281.826 239.839 283.388 221.28C285.921 224.102 287.409 228.255 289.641 231.4C300.801 247.128 320.426 253.928 338.961 248.664C341.889 247.833 349.187 245.587 350.334 242.601C349.911 241.742 350.117 241.982 349.462 241.321C351.21 240.276 353.081 238.927 354.778 237.763L355.977 238.263C361.677 236.839 366.593 227.165 368.774 222.009C370.076 218.933 371.352 213.775 373.203 211.225Z" fill="#CBCBCB"/>
+
<path d="M333.257 205.278C339.992 203.067 347.483 203.449 353.222 207.95C357.163 211.078 359.7 215.642 360.277 220.64C361.097 227.378 358.779 232.623 354.778 237.763C353.081 238.927 351.21 240.276 349.462 241.321C345.455 242.999 341.05 243.492 336.77 242.74C325.294 240.715 319.303 229.21 321.603 218.414C322.057 216.283 322.448 214.195 324.355 212.918C326.871 213.833 328.746 221.097 333.393 219.468C344.342 215.63 340.773 209.483 333.257 205.278Z" fill="black"/>
+
<path d="M625.558 235.792L626.383 235.212L626.893 235.539C627.171 238.122 626.046 248.279 625.84 251.667C624.884 267.447 628.721 277.364 634.095 291.7C630.102 295.894 626.598 299.651 623.047 304.289C615.222 315.313 610.008 326.032 607.851 339.533C607.262 343.228 607.249 346.606 606.803 349.998C607.026 355.87 607.712 360.568 608.757 366.327C589.863 342.096 585.862 314.845 593.435 285.243C597.579 269.032 610.627 244.458 625.558 235.792Z" fill="#EC7558"/>
+
<path d="M608.757 366.327C589.863 342.096 585.862 314.845 593.435 285.243C597.579 269.032 610.627 244.458 625.558 235.792C623.566 243.297 620.723 250.768 618.73 258.489C603.829 279.622 598.278 309.808 601.892 335.316C602.364 338.655 604.794 347.541 606.803 349.998C607.026 355.87 607.712 360.568 608.757 366.327Z" fill="#FCA78D"/>
+
<path d="M625.558 235.792L626.383 235.212L626.893 235.539C627.171 238.122 626.046 248.279 625.84 251.667C624.884 267.447 628.721 277.364 634.095 291.7C630.102 295.894 626.598 299.651 623.047 304.289C619.543 292.822 618.141 286.54 618.25 274.215C618.297 269.218 618.794 263.359 618.73 258.489C620.723 250.768 623.566 243.297 625.558 235.792Z" fill="#D25742"/>
+
<path d="M250.056 532.774C265.122 525.373 281.953 523.57 297.972 529.097C314.579 534.914 328.204 547.075 335.862 562.922C337.964 567.303 340.013 573.036 341.245 577.771C341.839 579.658 342.182 581.971 342.544 583.942C339.029 581.52 335.654 578.567 331.892 576.103C318.722 567.459 309.398 565.652 294.358 563.402C292.044 559.409 288.944 555.5 286.039 551.907C277.385 542.884 271.948 539.602 260.433 535.268C257.014 534.438 253.478 533.528 250.056 532.774Z" fill="#EC7558"/>
+
<path d="M250.056 532.774C265.122 525.373 281.953 523.57 297.972 529.097C314.579 534.914 328.204 547.075 335.862 562.922C337.964 567.303 340.013 573.036 341.245 577.771L340.477 577.897C338.511 576.322 335.538 572.969 333.703 571.014C330.307 562.728 317.204 552.556 310.12 547.231C297.038 537.404 276.645 532.374 260.433 535.268C257.014 534.438 253.478 533.528 250.056 532.774Z" fill="#FCA78D"/>
+
<path d="M286.039 551.907L286.705 552.075C290.92 553.112 294.861 553.508 299.098 554.253C306.878 555.626 314.373 558.289 321.278 562.126C325.432 564.434 330.278 569.502 333.703 571.014C335.538 572.969 338.511 576.322 340.477 577.897L341.245 577.771C341.839 579.658 342.182 581.971 342.544 583.942C339.029 581.52 335.654 578.567 331.892 576.103C318.722 567.459 309.398 565.652 294.358 563.402C292.044 559.409 288.944 555.5 286.039 551.907Z" fill="#D25742"/>
+
<path d="M466.218 275.965C467.739 275.235 469.942 276.847 471.344 277.661C476.55 280.347 481.486 280.473 484.961 282.05C477.177 307.207 470.691 350.412 470.695 376.699C464.344 378.485 460.659 378.649 454.35 376.367C450.504 374.5 448.862 373.153 446.389 369.81C455.377 339.079 462.007 307.706 466.218 275.965Z" fill="#EC7558"/>
+
<path d="M466.218 275.965C467.739 275.235 469.942 276.847 471.344 277.661C469.474 287.957 467.735 298.277 466.138 308.619C464.858 316.841 457.268 372.713 454.35 376.367C450.504 374.5 448.862 373.153 446.389 369.81C455.377 339.079 462.007 307.706 466.218 275.965Z" fill="#FCA78D"/>
+
<path d="M647.707 508.157C648.861 505.326 650.192 502.698 651.54 499.956C666.677 519.585 676.853 549.805 679.422 574.241L675.652 576.431C673.45 577.548 669.478 579.397 667.49 580.593C665.106 581.705 662.566 581.844 659.976 582.118C649.114 573.099 640.796 536.405 638.429 522.176L643.42 515.895C645.467 513.17 646.642 511.371 647.707 508.157Z" fill="#EC7558"/>
+
<path d="M638.429 522.176L643.42 515.895C646.553 523.73 648.343 530.896 650.828 538.895C655.541 554.047 660.187 566.583 667.49 580.593C665.106 581.705 662.566 581.844 659.976 582.118C649.114 573.099 640.796 536.405 638.429 522.176Z" fill="#D25742"/>
+
<path d="M647.707 508.157C648.861 505.326 650.192 502.698 651.54 499.956C666.677 519.585 676.853 549.805 679.422 574.241L675.652 576.431C673.812 570.926 672.889 563.301 671.31 557.733C667.212 543.267 658.131 518.296 647.707 508.157Z" fill="#FCA78D"/>
+
<path d="M182.872 500.019C183.958 502.441 185.002 504.884 186.004 507.34C187.554 510.428 188.852 513.022 190.652 515.958L195.727 522.18C192.603 538.718 188.941 554.54 181.975 569.969C178.112 578.529 176.652 585.151 166.162 580.534C163.495 579.211 161.471 578.036 158.911 576.494C157.823 575.787 156.44 574.704 155.36 573.916C158.781 545.723 166.105 523.136 182.872 500.019Z" fill="#EC7558"/>
+
<path d="M190.652 515.958L195.727 522.18C192.603 538.718 188.941 554.54 181.975 569.969C178.112 578.529 176.652 585.151 166.162 580.534C167.579 576.431 172.725 568.971 174.811 563.086C180.344 547.475 185.401 531.658 190.652 515.958Z" fill="#D25742"/>
+
<path d="M182.872 500.019C183.958 502.441 185.002 504.884 186.004 507.34C183.776 511.14 180.964 514.88 178.888 518.747C171.27 532.926 165.437 548.541 161.726 564.194C161.182 566.49 159.818 575.054 158.911 576.494C157.823 575.787 156.44 574.704 155.36 573.916C158.781 545.723 166.105 523.136 182.872 500.019Z" fill="#FCA78D"/>
+
<path d="M359.33 286.516C361.904 298.375 364.269 309.346 367.347 321.089C371.851 338.272 377.098 354.137 382.637 370.936C380.426 372.961 377.74 375.454 375.006 376.749C368.914 379.082 364.931 378.923 358.773 377.535C356.185 346.417 351.389 325.092 341.538 295.169C344.817 293.992 352.155 291.573 354.684 289.954C356.202 288.761 357.767 287.649 359.33 286.516Z" fill="#EC7558"/>
+
<path d="M359.33 286.516C361.904 298.375 364.269 309.346 367.347 321.089C371.851 338.272 377.098 354.137 382.637 370.936C380.426 372.961 377.74 375.454 375.006 376.749C373.576 375.505 364.592 331.266 363.501 326.202C361.072 314.928 357.175 301.169 354.684 289.954C356.202 288.761 357.767 287.649 359.33 286.516Z" fill="#FCA78D"/>
+
<path d="M603.812 376.34C608.963 380.474 613.192 382.848 618.718 386.365C620.099 387.245 622.58 390.304 624.307 391.546C631.867 396.98 639.857 398.724 648.87 399.246C647.45 402.583 646.153 405.527 643.988 408.451C633.147 423.101 607.097 435.262 596.758 412.991C596.425 412.322 596.143 411.622 595.911 410.91C592.984 401.989 596.56 388.602 600.645 380.481C601.622 379.143 602.713 377.553 603.812 376.34Z" fill="#D25742"/>
+
<path d="M600.645 380.481L600.923 380.812C605.514 386.382 608.732 393.046 614.544 397.654C618.579 400.851 622.976 403.042 627.503 405.422C618.848 413.686 608.635 419.841 596.758 412.991C596.425 412.322 596.143 411.622 595.911 410.91C592.984 401.989 596.56 388.602 600.645 380.481Z" fill="#EC7558"/>
+
<path d="M597.398 497.428C604.73 493.709 614.426 490.293 622.323 488.528C621.948 490.92 621.948 493.203 621.877 495.621C621.872 500.259 621.906 503.393 622.546 507.968C623.069 512.306 623.456 514.518 624.387 518.852C613.836 522.791 600.114 530.158 588.848 531.784C586.864 532.071 572.548 528.979 569.461 528.402C574.035 523.566 577.539 519.476 581.469 514.135C583.924 510.798 587.942 504.526 590.634 501.733L591.164 501.19C592.887 499.4 594.913 498.675 597.191 497.622L597.398 497.428Z" fill="#D25742"/>
+
<path d="M597.398 497.428C604.73 493.709 614.426 490.293 622.323 488.528C621.948 490.92 621.948 493.203 621.877 495.621C621.872 500.259 621.906 503.393 622.546 507.968L622.428 509.143C620.171 512.311 594.104 520.499 588.84 522.521C592.289 515.234 596.619 505.6 597.191 497.622L597.398 497.428Z" fill="#EC7558"/>
+
<path d="M597.398 497.428C604.73 493.709 614.426 490.293 622.323 488.528C621.948 490.92 621.948 493.203 621.877 495.621C614.316 495.726 609.347 496.805 602.107 498.983C601.049 499.303 598.409 500.272 597.608 499.867C597.533 499.063 597.44 498.233 597.398 497.428Z" fill="#FCA78D"/>
+
<path d="M213.837 519.552C217.5 509.914 228.538 500.849 239.13 501.935C245.841 502.622 249.501 505.474 253.712 510.289C255.671 512.458 256.563 514.299 257.979 516.847C257.955 518.747 258.334 518.128 257.393 519.202C246.094 523.098 241.058 526.401 231.579 533.659C229.128 535.538 222.959 536.494 220.461 538.794C216.766 541.709 212.848 546.448 209.684 550.074C209.155 546.212 208.101 539.838 209.406 536.039C210.011 529.994 211.249 525.007 213.837 519.552Z" fill="#EC7558"/>
+
<path d="M257.979 516.847C257.955 518.747 258.334 518.128 257.393 519.202C246.094 523.098 241.058 526.401 231.579 533.659C229.128 535.538 222.959 536.494 220.461 538.794C216.766 541.709 212.848 546.448 209.684 550.074C209.155 546.212 208.101 539.838 209.406 536.039C210.635 536.515 213.987 532.711 215.049 531.755C217.796 529.257 220.743 526.991 223.859 524.969C235.714 517.344 244.444 516.645 257.979 516.847Z" fill="#D25742"/>
+
<path d="M213.837 519.552C217.5 509.914 228.538 500.849 239.13 501.935C245.841 502.622 249.501 505.474 253.712 510.289C247.997 511.06 241.529 505.31 231.773 508.776C225.464 511.017 217.326 519.653 213.837 519.552Z" fill="#FCA78D"/>
+
<path d="M557.479 539.316C561.817 539.64 566.147 540.015 570.472 540.445C576.857 541.283 582.248 542.197 588.482 543.916C585.095 550.407 583.823 552.745 581.819 559.826C581.381 562.109 581.023 565.129 580.673 567.484C564.883 565.361 549.371 561.086 535.135 553.849C544.014 548.899 549.354 545.373 557.479 539.316Z" fill="#D25742"/>
+
<path d="M570.472 540.445C576.857 541.283 582.248 542.197 588.482 543.916C585.095 550.407 583.823 552.745 581.819 559.826C573.205 557.918 567.195 556.688 559.105 552.943C561.438 550.454 568.13 541.389 570.472 540.445Z" fill="#EC7558"/>
+
<path d="M598.893 574.388C601.875 572.32 605.543 569.915 608.26 567.602C610.341 583.449 610.387 592.51 608.833 608.374C603.471 608.466 599.727 608.311 594.395 607.498C592.091 606.832 590.032 605.749 587.972 604.553C588.465 597.026 590.196 585.648 591.792 578.285C594.033 576.798 596.492 575.618 598.893 574.388Z" fill="#EC7558"/>
+
<path d="M598.893 574.388C597.145 581.575 594.947 600.109 594.395 607.498C592.091 606.832 590.032 605.749 587.972 604.553C588.465 597.026 590.196 585.648 591.792 578.285C594.033 576.798 596.492 575.618 598.893 574.388Z" fill="#D25742"/>
+
<path d="M586.224 404.268L586.936 409.857C587.357 411.888 587.736 413.96 588.321 415.94C592.689 425.721 596.838 428.775 606.302 433.152C594.803 439.176 583.634 442.878 576.036 428.758C575.332 427.065 574.882 425.11 574.406 423.324C573.159 412.524 577.068 408.53 586.224 404.268Z" fill="#D25742"/>
+
<path d="M586.224 404.268L586.936 409.857C587.357 411.888 587.736 413.96 588.321 415.94C587.879 425.7 586.283 428.771 576.036 428.758C575.332 427.065 574.882 425.11 574.406 423.324C573.159 412.524 577.068 408.53 586.224 404.268Z" fill="#EC7558"/>
+
<path d="M586.224 404.268L586.936 409.857C579.098 414.327 578.218 420.279 574.406 423.324C573.159 412.524 577.068 408.53 586.224 404.268Z" fill="#FCA78D"/>
+
<path d="M197.154 579.026C197.662 575.277 198.643 569.3 201.095 566.292L201.768 566.301C203.289 569.472 202.02 579.039 202.508 583.024C203.087 587.754 204.564 592.303 205.441 596.878C206.576 600.64 208.934 604.962 210.813 608.395C204.667 609.625 201.456 609.334 195.281 608.411C195.278 597.017 195.42 590.307 197.154 579.026Z" fill="#EC7558"/>
+
<path d="M197.154 579.026C197.662 575.277 198.643 569.3 201.095 566.292L201.768 566.301C203.289 569.472 202.02 579.039 202.508 583.024C203.087 587.754 204.564 592.303 205.441 596.878C199.377 591.933 200.904 582.236 197.154 579.026Z" fill="#D25742"/>
+
<path d="M467.402 263.636C474.954 265.476 481.339 266.926 489.253 266.998C488.697 268.859 484.994 280.866 484.961 282.05C481.486 280.473 476.55 280.347 471.344 277.661C469.942 276.847 467.739 275.235 466.218 275.965C466.821 271.899 467.082 267.739 467.402 263.636Z" fill="#D25742"/>
+
<path d="M252.921 497.626C259.809 496.641 266.365 495.971 272.284 500.179C274.044 501.594 275.387 502.782 276.552 504.762C278.355 508.313 279.456 512.176 279.793 516.144C275.03 516.009 272.218 515.988 267.543 516.94C266.041 512.942 264.829 509.855 262.506 506.232C260.922 504.139 259.601 502.904 257.729 501.072L252.921 497.626Z" fill="#D25742"/>
+
<path d="M252.921 497.626C259.809 496.641 266.365 495.971 272.284 500.179C274.044 501.594 275.387 502.782 276.552 504.762C274.329 506.038 265.462 506.203 262.506 506.232C260.922 504.139 259.601 502.904 257.729 501.072L252.921 497.626Z" fill="#EC7558"/>
+
<path d="M252.921 497.626C259.809 496.641 266.365 495.971 272.284 500.179C270.488 500.992 260.509 500.958 257.729 501.072L252.921 497.626Z" fill="#FCA78D"/>
+
<path d="M336.713 282.365C344.588 280.83 349.971 278.946 357.217 275.903C358.042 279.276 358.42 282.855 359.33 286.516C357.767 287.649 356.202 288.761 354.684 289.954C352.155 291.573 344.817 293.992 341.538 295.169C340.002 290.926 338.311 286.597 336.713 282.365Z" fill="#D25742"/>
+
<path d="M671.26 591.453C673.584 590.644 675.067 590.079 677.333 589.052C675.96 597.569 673.955 610.429 670.219 618.096C669.924 615.193 669.655 612.182 669.28 609.296C668.32 603.681 667.612 598.028 667.153 592.354L671.26 591.453Z" fill="#EC7558"/>
+
<path d="M667.153 592.354L671.26 591.453L671.217 592.042C670.834 597.662 672.544 605.829 669.343 609.166C669.322 609.208 669.301 609.254 669.28 609.296C668.32 603.681 667.612 598.028 667.153 592.354Z" fill="#D25742"/>
+
<path d="M157.009 589.068C159.236 590.269 160.694 590.854 163.033 591.794C164.483 592.211 165.564 592.295 166.574 593.222C166.867 596.587 165.239 605.951 164.652 609.768C164.326 612.161 164.155 615.775 163.958 618.268C160.692 609.696 158.265 598.192 157.009 589.068Z" fill="#EC7558"/>
+
<path d="M163.033 591.794C164.483 592.211 165.564 592.295 166.574 593.222C166.867 596.587 165.239 605.951 164.652 609.768C161.661 605.341 162.836 597.417 163.033 591.794Z" fill="#D25742"/>
+
<path d="M211.553 488.987C216.335 490.048 221.749 492.256 226.393 493.999C223.819 495.234 222.13 496.215 219.681 497.689C216.714 499.791 215.023 501.636 212.573 504.294C212.422 499.202 212.246 494.025 211.553 488.987Z" fill="#EC7558"/>
+
<path d="M196.844 491.59C198.239 491.371 198.782 491.131 200.01 491.796C202.048 494.454 200.981 506.072 199.49 507.778L198.925 507.268C196.032 501.957 194.299 497.706 192.086 492.146L196.844 491.59Z" fill="#FCA78D"/>
+
<path d="M597.048 551.692C599.495 553.032 601.525 554.321 603.876 555.824C601.226 558.419 599.104 560.344 596.265 562.728L592.474 565.391C593.472 559.599 594.458 556.966 597.048 551.692Z" fill="#EC7558"/>
+
<path d="M633.21 490.832L641.916 491.986L639.039 499.278C636.988 499.674 634.92 499.998 632.844 500.259C632.836 496.788 632.928 494.282 633.21 490.832Z" fill="#FCA78D"/>
+
<path d="M632.844 500.259C634.92 499.998 636.988 499.674 639.039 499.278C637.409 503.305 636.07 505.794 634.015 509.589C633.37 506.662 632.873 503.254 632.844 500.259Z" fill="#D25742"/>
+
<path d="M491.422 121.246C506.154 119.363 523.743 128.481 530.65 141.675C533.169 146.488 535.161 157.281 526.072 151.976C518.878 147.095 511.512 143.055 502.705 142.242C493.999 141.437 487.172 143.109 481.65 134.935C481.654 126.681 482.724 122.946 491.422 121.246Z" fill="black"/>
+
<path d="M492.184 126.622C500.287 127.02 508.892 129.327 515.601 134.01C517.711 135.482 523.262 140.138 523.696 142.613C522.429 142.66 521.199 141.918 519.944 141.364C514.687 138.908 514.03 138.809 508.239 137.06C502.376 134.908 493.789 136.808 488.57 133.84C485.138 131.887 490.579 127.564 492.184 126.622Z" fill="#EC7558"/>
+
<path d="M309.961 139.77C317.428 139.409 328.596 140.814 327.16 152.414C326.157 160.521 308.613 160.168 302.692 162.332C298.002 164.046 294.656 165.825 290.065 169.1C287.524 171.146 282.836 175.573 279.09 174.133C277.361 173.484 276.722 170.793 277.023 169.107C279.005 158.016 289.127 148.312 298.889 143.521C302.665 141.668 306.123 140.667 309.961 139.77Z" fill="black"/>
+
<path d="M312.587 145.641C316.353 145.327 321.957 146.525 320.6 151.582C320.284 151.999 319.685 152.874 319.158 152.987C312.082 154.509 304.967 155.399 298.16 158.044C295.422 159.108 293.108 160.245 290.498 161.628C287.819 163.388 286.43 164.538 283.982 166.644C292.022 154.04 298.117 149.15 312.587 145.641Z" fill="#EC7558"/>
+
<defs>
+
<linearGradient id="paint0_linear_43_123" x1="1473.5" y1="0" x2="1473.5" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint1_linear_43_123" x1="1473.5" y1="0" x2="1473.5" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint2_linear_43_123" x1="1473.5" y1="0" x2="1473.5" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint3_linear_43_123" x1="1473.5" y1="0" x2="1473.5" y2="825" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint4_linear_43_123" x1="1478.64" y1="825" x2="1477.68" y2="-0.0108598" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint5_linear_43_123" x1="1478.64" y1="825" x2="1477.68" y2="-0.0108598" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint6_linear_43_123" x1="1478.64" y1="825" x2="1477.68" y2="-0.0108598" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
<linearGradient id="paint7_linear_43_123" x1="1478.64" y1="825" x2="1477.68" y2="-0.0108598" gradientUnits="userSpaceOnUse">
+
<stop stop-color="white" stop-opacity="0.98"/>
+
<stop offset="1" stop-color="#E9E9EE"/>
+
</linearGradient>
+
</defs>
+
</svg>
+180 -136
lib/screens/auth/login_screen.dart
···
import 'package:flutter/material.dart';
+
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
···
super.dispose();
}
+
void _showHandleHelpDialog() {
+
showDialog(
+
context: context,
+
builder: (context) => AlertDialog(
+
backgroundColor: const Color(0xFF1A2028),
+
title: const Text(
+
'What is a handle?',
+
style: TextStyle(color: Colors.white),
+
),
+
content: const Text(
+
'Your handle is your unique identifier '
+
'on the atproto network, like '
+
'alice.bsky.social. If you don\'t have one '
+
'yet, you can create an account at bsky.app.',
+
style: TextStyle(color: AppColors.textSecondary),
+
),
+
actions: [
+
TextButton(
+
onPressed: () => Navigator.of(context).pop(),
+
child: const Text('Got it'),
+
),
+
],
+
),
+
);
+
}
+
Future<void> _handleSignIn() async {
if (!_formKey.currentState!.validate()) {
return;
···
}
} on Exception catch (e) {
if (mounted) {
+
final errorString = e.toString().toLowerCase();
+
String userMessage;
+
if (errorString.contains('timeout') ||
+
errorString.contains('socketexception') ||
+
errorString.contains('connection')) {
+
userMessage =
+
'Network error. Please check your connection and try again.';
+
} else if (errorString.contains('404') ||
+
errorString.contains('not found')) {
+
userMessage =
+
'Handle not found. Please verify your handle is correct.';
+
} else if (errorString.contains('401') ||
+
errorString.contains('403') ||
+
errorString.contains('unauthorized')) {
+
userMessage = 'Authorization failed. Please try again.';
+
} else {
+
userMessage = 'Sign in failed. Please try again later.';
+
debugPrint('Sign in error: $e');
+
}
+
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
-
content: Text('Sign in failed: ${e.toString()}'),
+
content: Text(userMessage),
backgroundColor: Colors.red[700],
),
);
···
}
},
child: Scaffold(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
+
resizeToAvoidBottomInset: false,
appBar: AppBar(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
foregroundColor: Colors.white,
-
title: const Text('Sign In'),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
),
-
body: SafeArea(
-
child: Padding(
-
padding: const EdgeInsets.all(24),
-
child: Form(
-
key: _formKey,
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.stretch,
-
children: [
-
const SizedBox(height: 32),
-
-
// Title
-
const Text(
-
'Enter your handle',
-
style: TextStyle(
-
fontSize: 24,
-
color: Colors.white,
-
fontWeight: FontWeight.bold,
-
),
-
textAlign: TextAlign.center,
-
),
-
-
const SizedBox(height: 8),
-
-
// Subtitle
-
const Text(
-
'Sign in with your atProto handle to continue',
-
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
-
textAlign: TextAlign.center,
-
),
-
-
const SizedBox(height: 48),
-
-
// Handle input field
-
TextFormField(
-
controller: _handleController,
-
enabled: !_isLoading,
-
style: const TextStyle(color: Colors.white),
-
decoration: InputDecoration(
-
hintText: 'alice.bsky.social',
-
hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
-
filled: true,
-
fillColor: const Color(0xFF1A2028),
-
border: OutlineInputBorder(
-
borderRadius: BorderRadius.circular(12),
-
borderSide: const BorderSide(color: Color(0xFF2A3441)),
+
body: GestureDetector(
+
behavior: HitTestBehavior.opaque,
+
onTap: () => FocusScope.of(context).unfocus(),
+
child: SafeArea(
+
child: Padding(
+
padding: const EdgeInsets.all(24),
+
child: Form(
+
key: _formKey,
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.stretch,
+
children: [
+
const SizedBox(height: 32),
+
+
// Title
+
const Text(
+
'Enter your atproto handle',
+
style: TextStyle(
+
fontSize: 24,
+
color: Colors.white,
+
fontWeight: FontWeight.bold,
),
-
enabledBorder: OutlineInputBorder(
-
borderRadius: BorderRadius.circular(12),
-
borderSide: const BorderSide(color: Color(0xFF2A3441)),
+
textAlign: TextAlign.center,
+
),
+
+
const SizedBox(height: 12),
+
+
// Provider logos
+
Center(
+
child: SvgPicture.asset(
+
'assets/icons/atproto/providers_stack.svg',
+
height: 24,
+
errorBuilder: (context, error, stackTrace) {
+
debugPrint(
+
'Failed to load providers_stack.svg: $error',
+
);
+
return const SizedBox(height: 24);
+
},
),
-
focusedBorder: OutlineInputBorder(
-
borderRadius: BorderRadius.circular(12),
-
borderSide: const BorderSide(
-
color: AppColors.primary,
-
width: 2,
+
),
+
+
const SizedBox(height: 32),
+
+
// Handle input field
+
TextFormField(
+
controller: _handleController,
+
enabled: !_isLoading,
+
style: const TextStyle(color: Colors.white),
+
decoration: InputDecoration(
+
hintText: 'alice.bsky.social',
+
hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
+
filled: true,
+
fillColor: const Color(0xFF1A2028),
+
border: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(
+
color: Color(0xFF2A3441),
+
),
),
+
enabledBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(
+
color: Color(0xFF2A3441),
+
),
+
),
+
focusedBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(
+
color: AppColors.primary,
+
width: 2,
+
),
+
),
+
prefixIcon: const Padding(
+
padding: EdgeInsets.only(left: 16, right: 8),
+
child: Text(
+
'@',
+
style: TextStyle(
+
color: Color(0xFF5A6B7F),
+
fontSize: 18,
+
fontWeight: FontWeight.w500,
+
),
+
),
+
),
+
prefixIconConstraints: const BoxConstraints(),
),
-
prefixIcon: const Icon(
-
Icons.person,
-
color: Color(0xFF5A6B7F),
-
),
-
),
-
keyboardType: TextInputType.emailAddress,
-
autocorrect: false,
-
textInputAction: TextInputAction.done,
-
onFieldSubmitted: (_) => _handleSignIn(),
-
validator: (value) {
-
if (value == null || value.trim().isEmpty) {
-
return 'Please enter your handle';
-
}
-
// Basic handle validation
-
if (!value.contains('.')) {
-
return 'Handle must contain a domain '
-
'(e.g., user.bsky.social)';
-
}
-
return null;
-
},
-
),
-
-
const SizedBox(height: 32),
-
-
// Sign in button
-
PrimaryButton(
-
title: _isLoading ? 'Signing in...' : 'Sign In',
-
onPressed: _isLoading ? () {} : _handleSignIn,
-
disabled: _isLoading,
-
),
-
-
const SizedBox(height: 24),
-
-
// Info text
-
const Text(
-
'You\'ll be redirected to authorize this app with your '
-
'atProto provider.',
-
style: TextStyle(fontSize: 14, color: Color(0xFF5A6B7F)),
-
textAlign: TextAlign.center,
-
),
-
-
const Spacer(),
-
-
// Help text
-
Center(
-
child: TextButton(
-
onPressed: () {
-
showDialog(
-
context: context,
-
builder:
-
(context) => AlertDialog(
-
backgroundColor: const Color(0xFF1A2028),
-
title: const Text(
-
'What is a handle?',
-
style: TextStyle(color: Colors.white),
-
),
-
content: const Text(
-
'Your handle is your unique identifier '
-
'on the atProto network, like '
-
'alice.bsky.social. If you don\'t have one '
-
'yet, you can create an account at bsky.app.',
-
style: TextStyle(color: Color(0xFFB6C2D2)),
-
),
-
actions: [
-
TextButton(
-
onPressed:
-
() => Navigator.of(context).pop(),
-
child: const Text('Got it'),
-
),
-
],
-
),
-
);
+
keyboardType: TextInputType.emailAddress,
+
autocorrect: false,
+
textInputAction: TextInputAction.done,
+
onFieldSubmitted: (_) => _handleSignIn(),
+
validator: (value) {
+
if (value == null || value.trim().isEmpty) {
+
return 'Please enter your handle';
+
}
+
// Basic handle validation
+
if (!value.contains('.')) {
+
return 'Handle must contain a domain '
+
'(e.g., user.bsky.social)';
+
}
+
return null;
},
-
child: const Text(
-
'What is a handle?',
-
style: TextStyle(
-
color: AppColors.primary,
-
decoration: TextDecoration.underline,
+
),
+
+
const SizedBox(height: 32),
+
+
// Sign in button
+
PrimaryButton(
+
title: _isLoading ? 'Signing in...' : 'Sign In',
+
onPressed: _isLoading ? () {} : _handleSignIn,
+
disabled: _isLoading,
+
),
+
+
const SizedBox(height: 24),
+
+
// Info text
+
const Text(
+
'You\'ll be redirected to authorize this app with your '
+
'atproto provider.',
+
style: TextStyle(fontSize: 14, color: Color(0xFF5A6B7F)),
+
textAlign: TextAlign.center,
+
),
+
+
const Spacer(),
+
+
// Help text
+
Center(
+
child: TextButton(
+
onPressed: _showHandleHelpDialog,
+
child: const Text(
+
'What is a handle?',
+
style: TextStyle(
+
color: AppColors.primary,
+
decoration: TextDecoration.underline,
+
),
),
),
),
-
),
-
],
+
],
+
),
),
),
),
+50 -11
lib/screens/landing_screen.dart
···
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
+
+
import '../constants/app_colors.dart';
import '../widgets/primary_button.dart';
class LandingScreen extends StatelessWidget {
···
@override
Widget build(BuildContext context) {
return Scaffold(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
body: SafeArea(
child: Center(
child: Padding(
···
'assets/logo/lil_dude.svg',
width: 120,
height: 120,
+
errorBuilder: (context, error, stackTrace) {
+
debugPrint('Failed to load lil_dude.svg: $error');
+
return const SizedBox(width: 120, height: 120);
+
},
),
const SizedBox(height: 16),
···
'assets/logo/coves_bubble.svg',
width: 180,
height: 60,
+
errorBuilder: (context, error, stackTrace) {
+
debugPrint('Failed to load coves_bubble.svg: $error');
+
return const SizedBox(width: 180, height: 60);
+
},
),
const SizedBox(height: 48),
-
// Buttons
+
// "Bring your @handle" with logos
+
Row(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
const Text(
+
'Bring your atproto handle',
+
style: TextStyle(
+
fontSize: 14,
+
color: Color(0xFF8A96A6),
+
fontWeight: FontWeight.w500,
+
),
+
),
+
const SizedBox(width: 8),
+
SvgPicture.asset(
+
'assets/icons/atproto/providers_landing.svg',
+
height: 18,
+
errorBuilder: (context, error, stackTrace) {
+
debugPrint(
+
'Failed to load providers_landing.svg: $error',
+
);
+
return const SizedBox(height: 18);
+
},
+
),
+
],
+
),
+
+
const SizedBox(height: 16),
+
+
// Sign in button
PrimaryButton(
-
title: 'Create account',
+
title: 'Sign in',
onPressed: () {
-
ScaffoldMessenger.of(context).showSnackBar(
-
const SnackBar(
-
content: Text('Account registration coming soon!'),
-
duration: Duration(seconds: 2),
-
),
-
);
+
context.go('/login');
},
),
const SizedBox(height: 12),
+
// Create account button
PrimaryButton(
-
title: 'Sign in',
+
title: 'Create account',
onPressed: () {
-
context.go('/login');
+
ScaffoldMessenger.of(context).showSnackBar(
+
const SnackBar(
+
content: Text('Account registration coming soon!'),
+
duration: Duration(seconds: 2),
+
),
+
);
},
variant: ButtonVariant.outline,
),
+1
pubspec.yaml
···
- assets/logo/coves_bubble.svg
- assets/logo/lil_dude.svg
- assets/icons/
+
- assets/icons/atproto/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
+3
.gitignore
···
# macOS (not targeting this platform)
macos/
+
+
# Claude Code local settings
+
.claude/settings.local.json