Compare changes

Choose any two refs to compare.

+11
lib/main.dart
···
import 'screens/home/main_shell_screen.dart';
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
import 'widgets/loading_error_states.dart';
···
signOutHandler: authProvider.signOut,
);
runApp(
MultiProvider(
providers: [
···
(context) => CommentsProvider(
authProvider,
voteProvider: context.read<VoteProvider>(),
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
···
CommentsProvider(
auth,
voteProvider: vote,
);
},
),
···
import 'screens/home/main_shell_screen.dart';
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
+
import 'services/comment_service.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
import 'widgets/loading_error_states.dart';
···
signOutHandler: authProvider.signOut,
);
+
// Initialize comment service with auth callbacks
+
// Comments go through the Coves backend (which proxies to PDS with DPoP)
+
final commentService = CommentService(
+
sessionGetter: () async => authProvider.session,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
);
+
runApp(
MultiProvider(
providers: [
···
(context) => CommentsProvider(
authProvider,
voteProvider: context.read<VoteProvider>(),
+
commentService: commentService,
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
···
CommentsProvider(
auth,
voteProvider: vote,
+
commentService: commentService,
);
},
),
+447 -8
test/providers/comments_provider_test.dart
···
import 'package:coves_flutter/providers/auth_provider.dart';
import 'package:coves_flutter/providers/comments_provider.dart';
import 'package:coves_flutter/providers/vote_provider.dart';
import 'package:coves_flutter/services/coves_api_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
···
import 'comments_provider_test.mocks.dart';
// Generate mocks for dependencies
-
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CommentsProvider', () {
late CommentsProvider commentsProvider;
late MockAuthProvider mockAuthProvider;
late MockCovesApiService mockApiService;
···
});
group('loadComments', () {
-
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
-
test('should load comments successfully', () async {
final mockComments = [
_createMockThreadComment('comment1'),
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
),
).thenAnswer((_) async => secondResponse);
-
await commentsProvider.loadComments(postUri: testPostUri);
expect(commentsProvider.comments.length, 2);
expect(commentsProvider.comments[0].comment.uri, 'comment1');
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
// Load different post
const differentPostUri =
'at://did:plc:test/social.coves.post.record/456';
final secondResponse = CommentsResponse(
post: {},
comments: [_createMockThreadComment('comment2')],
···
await commentsProvider.loadComments(
postUri: differentPostUri,
refresh: true,
);
···
// Start first load
final firstFuture = commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
// Try to load again while still loading - should schedule a refresh
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
expect(commentsProvider.currentTimeNotifier.value, null);
await commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
refresh: true,
);
···
).thenAnswer((_) async => response);
await commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
refresh: true,
);
···
).thenAnswer((_) async => response);
await commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
refresh: true,
);
···
});
final loadFuture = commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
// Load first page (refresh)
await commentsProvider.loadComments(
postUri: testPostUri,
refresh: true,
);
···
},
);
});
});
}
···
import 'package:coves_flutter/providers/auth_provider.dart';
import 'package:coves_flutter/providers/comments_provider.dart';
import 'package:coves_flutter/providers/vote_provider.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/comment_service.dart';
import 'package:coves_flutter/services/coves_api_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
···
import 'comments_provider_test.mocks.dart';
// Generate mocks for dependencies
+
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider, CommentService])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CommentsProvider', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
const testPostCid = 'test-post-cid';
+
late CommentsProvider commentsProvider;
late MockAuthProvider mockAuthProvider;
late MockCovesApiService mockApiService;
···
});
group('loadComments', () {
test('should load comments successfully', () async {
final mockComments = [
_createMockThreadComment('comment1'),
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
),
).thenAnswer((_) async => secondResponse);
+
await commentsProvider.loadComments(postUri: testPostUri, postCid: testPostCid);
expect(commentsProvider.comments.length, 2);
expect(commentsProvider.comments[0].comment.uri, 'comment1');
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
// Load different post
const differentPostUri =
'at://did:plc:test/social.coves.post.record/456';
+
const differentPostCid = 'different-post-cid';
final secondResponse = CommentsResponse(
post: {},
comments: [_createMockThreadComment('comment2')],
···
await commentsProvider.loadComments(
postUri: differentPostUri,
+
postCid: differentPostCid,
refresh: true,
);
···
// Start first load
final firstFuture = commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
// Try to load again while still loading - should schedule a refresh
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
expect(commentsProvider.currentTimeNotifier.value, null);
await commentsProvider.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
).thenAnswer((_) async => response);
await commentsProvider.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
).thenAnswer((_) async => response);
await commentsProvider.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
});
final loadFuture = commentsProvider.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
// Load first page (refresh)
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
},
);
});
+
+
group('createComment', () {
+
late MockCommentService mockCommentService;
+
late CommentsProvider providerWithCommentService;
+
+
setUp(() {
+
mockCommentService = MockCommentService();
+
+
// Setup mock API service for loadComments
+
final mockResponse = CommentsResponse(
+
post: {},
+
comments: [_createMockThreadComment('comment1')],
+
);
+
when(
+
mockApiService.getComments(
+
postUri: anyNamed('postUri'),
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
depth: anyNamed('depth'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
providerWithCommentService = CommentsProvider(
+
mockAuthProvider,
+
apiService: mockApiService,
+
voteProvider: mockVoteProvider,
+
commentService: mockCommentService,
+
);
+
});
+
+
tearDown(() {
+
providerWithCommentService.dispose();
+
});
+
+
test('should throw ValidationException for empty content', () async {
+
// First load comments to set up post context
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
expect(
+
() => providerWithCommentService.createComment(content: ''),
+
throwsA(
+
isA<ValidationException>().having(
+
(e) => e.message,
+
'message',
+
contains('empty'),
+
),
+
),
+
);
+
});
+
+
test('should throw ValidationException for whitespace-only content', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
expect(
+
() => providerWithCommentService.createComment(content: ' \n\t '),
+
throwsA(isA<ValidationException>()),
+
);
+
});
+
+
test('should throw ValidationException for content exceeding limit', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
// Create a string longer than 10000 characters
+
final longContent = 'a' * 10001;
+
+
expect(
+
() => providerWithCommentService.createComment(content: longContent),
+
throwsA(
+
isA<ValidationException>().having(
+
(e) => e.message,
+
'message',
+
contains('too long'),
+
),
+
),
+
);
+
});
+
+
test('should count emoji correctly in character limit', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
// Each emoji should count as 1 character, not 2-4 bytes
+
// 9999 'a' chars + 1 emoji = 10000 chars (should pass)
+
final contentAtLimit = '${'a' * 9999}๐Ÿ˜€';
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
// This should NOT throw
+
await providerWithCommentService.createComment(content: contentAtLimit);
+
+
verify(
+
mockCommentService.createComment(
+
rootUri: testPostUri,
+
rootCid: testPostCid,
+
parentUri: testPostUri,
+
parentCid: testPostCid,
+
content: contentAtLimit,
+
),
+
).called(1);
+
});
+
+
test('should throw ApiException when no post loaded', () async {
+
// Don't call loadComments first - no post context
+
+
expect(
+
() => providerWithCommentService.createComment(
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('No post loaded'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException when no CommentService', () async {
+
// Create provider without CommentService
+
final providerWithoutService = CommentsProvider(
+
mockAuthProvider,
+
apiService: mockApiService,
+
voteProvider: mockVoteProvider,
+
);
+
+
await providerWithoutService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
expect(
+
() => providerWithoutService.createComment(content: 'Test comment'),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('CommentService not available'),
+
),
+
),
+
);
+
+
providerWithoutService.dispose();
+
});
+
+
test('should create top-level comment (reply to post)', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
await providerWithCommentService.createComment(
+
content: 'This is a test comment',
+
);
+
+
// Verify the comment service was called with correct parameters
+
// Root and parent should both be the post for top-level comments
+
verify(
+
mockCommentService.createComment(
+
rootUri: testPostUri,
+
rootCid: testPostCid,
+
parentUri: testPostUri,
+
parentCid: testPostCid,
+
content: 'This is a test comment',
+
),
+
).called(1);
+
});
+
+
test('should create nested comment (reply to comment)', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/reply1',
+
cid: 'cidReply',
+
),
+
);
+
+
// Create a parent comment to reply to
+
final parentComment = _createMockThreadComment('parent-comment');
+
+
await providerWithCommentService.createComment(
+
content: 'This is a nested reply',
+
parentComment: parentComment,
+
);
+
+
// Root should still be the post, but parent should be the comment
+
verify(
+
mockCommentService.createComment(
+
rootUri: testPostUri,
+
rootCid: testPostCid,
+
parentUri: 'parent-comment',
+
parentCid: 'cid-parent-comment',
+
content: 'This is a nested reply',
+
),
+
).called(1);
+
});
+
+
test('should trim content before sending', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
await providerWithCommentService.createComment(
+
content: ' Hello world! ',
+
);
+
+
// Verify trimmed content was sent
+
verify(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: 'Hello world!',
+
),
+
).called(1);
+
});
+
+
test('should refresh comments after successful creation', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
await providerWithCommentService.createComment(
+
content: 'Test comment',
+
);
+
+
// Should have called getComments twice - once for initial load,
+
// once for refresh after comment creation
+
verify(
+
mockApiService.getComments(
+
postUri: anyNamed('postUri'),
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
depth: anyNamed('depth'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).called(2);
+
});
+
+
test('should rethrow exception from CommentService', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenThrow(ApiException('Network error'));
+
+
expect(
+
() => providerWithCommentService.createComment(
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('Network error'),
+
),
+
),
+
);
+
});
+
+
test('should accept content at exactly max length', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
final contentAtLimit = 'a' * CommentsProvider.maxCommentLength;
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
// Should not throw
+
await providerWithCommentService.createComment(content: contentAtLimit);
+
+
verify(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: contentAtLimit,
+
),
+
).called(1);
+
});
+
});
});
}
+91 -44
test/providers/comments_provider_test.mocks.dart
···
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
-
import 'dart:async' as _i5;
-
import 'dart:ui' as _i6;
import 'package:coves_flutter/models/comment.dart' as _i3;
import 'package:coves_flutter/models/post.dart' as _i2;
-
import 'package:coves_flutter/providers/auth_provider.dart' as _i4;
-
import 'package:coves_flutter/providers/vote_provider.dart' as _i8;
-
import 'package:coves_flutter/services/coves_api_service.dart' as _i7;
import 'package:mockito/mockito.dart' as _i1;
// ignore_for_file: type=lint
···
: super(parent, parentInvocation);
}
/// A class which mocks [AuthProvider].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockAuthProvider extends _i1.Mock implements _i4.AuthProvider {
MockAuthProvider() {
_i1.throwOnMissingStub(this);
}
···
as bool);
@override
-
_i5.Future<String?> getAccessToken() =>
(super.noSuchMethod(
Invocation.method(#getAccessToken, []),
-
returnValue: _i5.Future<String?>.value(),
)
-
as _i5.Future<String?>);
@override
-
_i5.Future<void> initialize() =>
(super.noSuchMethod(
Invocation.method(#initialize, []),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
)
-
as _i5.Future<void>);
@override
-
_i5.Future<void> signIn(String? handle) =>
(super.noSuchMethod(
Invocation.method(#signIn, [handle]),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
)
-
as _i5.Future<void>);
@override
-
_i5.Future<void> signOut() =>
(super.noSuchMethod(
Invocation.method(#signOut, []),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
)
-
as _i5.Future<void>);
@override
-
_i5.Future<bool> refreshToken() =>
(super.noSuchMethod(
Invocation.method(#refreshToken, []),
-
returnValue: _i5.Future<bool>.value(false),
)
-
as _i5.Future<bool>);
@override
void clearError() => super.noSuchMethod(
···
);
@override
-
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
-
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
/// A class which mocks [CovesApiService].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockCovesApiService extends _i1.Mock implements _i7.CovesApiService {
MockCovesApiService() {
_i1.throwOnMissingStub(this);
}
@override
-
_i5.Future<_i2.TimelineResponse> getTimeline({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getTimeline, [], {
···
),
),
)
-
as _i5.Future<_i2.TimelineResponse>);
@override
-
_i5.Future<_i2.TimelineResponse> getDiscover({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getDiscover, [], {
···
),
),
)
-
as _i5.Future<_i2.TimelineResponse>);
@override
-
_i5.Future<_i3.CommentsResponse> getComments({
required String? postUri,
String? sort = 'hot',
String? timeframe,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i3.CommentsResponse>.value(
_FakeCommentsResponse_1(
this,
Invocation.method(#getComments, [], {
···
),
),
)
-
as _i5.Future<_i3.CommentsResponse>);
@override
void dispose() => super.noSuchMethod(
···
/// A class which mocks [VoteProvider].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockVoteProvider extends _i1.Mock implements _i8.VoteProvider {
MockVoteProvider() {
_i1.throwOnMissingStub(this);
}
···
);
@override
-
_i8.VoteState? getVoteState(String? postUri) =>
(super.noSuchMethod(Invocation.method(#getVoteState, [postUri]))
-
as _i8.VoteState?);
@override
bool isLiked(String? postUri) =>
···
as int);
@override
-
_i5.Future<bool> toggleVote({
required String? postUri,
required String? postCid,
String? direction = 'up',
···
#postCid: postCid,
#direction: direction,
}),
-
returnValue: _i5.Future<bool>.value(false),
)
-
as _i5.Future<bool>);
@override
void setInitialVoteState({
···
);
@override
-
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
-
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
returnValueForMissingStub: null,
);
}
···
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i6;
+
import 'dart:ui' as _i7;
import 'package:coves_flutter/models/comment.dart' as _i3;
import 'package:coves_flutter/models/post.dart' as _i2;
+
import 'package:coves_flutter/providers/auth_provider.dart' as _i5;
+
import 'package:coves_flutter/providers/vote_provider.dart' as _i9;
+
import 'package:coves_flutter/services/comment_service.dart' as _i4;
+
import 'package:coves_flutter/services/coves_api_service.dart' as _i8;
import 'package:mockito/mockito.dart' as _i1;
// ignore_for_file: type=lint
···
: super(parent, parentInvocation);
}
+
class _FakeCreateCommentResponse_2 extends _i1.SmartFake
+
implements _i4.CreateCommentResponse {
+
_FakeCreateCommentResponse_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
/// A class which mocks [AuthProvider].
///
/// See the documentation for Mockito's code generation for more information.
+
class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider {
MockAuthProvider() {
_i1.throwOnMissingStub(this);
}
···
as bool);
@override
+
_i6.Future<String?> getAccessToken() =>
(super.noSuchMethod(
Invocation.method(#getAccessToken, []),
+
returnValue: _i6.Future<String?>.value(),
)
+
as _i6.Future<String?>);
@override
+
_i6.Future<void> initialize() =>
(super.noSuchMethod(
Invocation.method(#initialize, []),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
+
as _i6.Future<void>);
@override
+
_i6.Future<void> signIn(String? handle) =>
(super.noSuchMethod(
Invocation.method(#signIn, [handle]),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
+
as _i6.Future<void>);
@override
+
_i6.Future<void> signOut() =>
(super.noSuchMethod(
Invocation.method(#signOut, []),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
+
as _i6.Future<void>);
@override
+
_i6.Future<bool> refreshToken() =>
(super.noSuchMethod(
Invocation.method(#refreshToken, []),
+
returnValue: _i6.Future<bool>.value(false),
)
+
as _i6.Future<bool>);
@override
void clearError() => super.noSuchMethod(
···
);
@override
+
void addListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
+
void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
/// A class which mocks [CovesApiService].
///
/// See the documentation for Mockito's code generation for more information.
+
class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService {
MockCovesApiService() {
_i1.throwOnMissingStub(this);
}
@override
+
_i6.Future<_i2.TimelineResponse> getTimeline({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
+
returnValue: _i6.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getTimeline, [], {
···
),
),
)
+
as _i6.Future<_i2.TimelineResponse>);
@override
+
_i6.Future<_i2.TimelineResponse> getDiscover({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
+
returnValue: _i6.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getDiscover, [], {
···
),
),
)
+
as _i6.Future<_i2.TimelineResponse>);
@override
+
_i6.Future<_i3.CommentsResponse> getComments({
required String? postUri,
String? sort = 'hot',
String? timeframe,
···
#limit: limit,
#cursor: cursor,
}),
+
returnValue: _i6.Future<_i3.CommentsResponse>.value(
_FakeCommentsResponse_1(
this,
Invocation.method(#getComments, [], {
···
),
),
)
+
as _i6.Future<_i3.CommentsResponse>);
@override
void dispose() => super.noSuchMethod(
···
/// A class which mocks [VoteProvider].
///
/// See the documentation for Mockito's code generation for more information.
+
class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider {
MockVoteProvider() {
_i1.throwOnMissingStub(this);
}
···
);
@override
+
_i9.VoteState? getVoteState(String? postUri) =>
(super.noSuchMethod(Invocation.method(#getVoteState, [postUri]))
+
as _i9.VoteState?);
@override
bool isLiked(String? postUri) =>
···
as int);
@override
+
_i6.Future<bool> toggleVote({
required String? postUri,
required String? postCid,
String? direction = 'up',
···
#postCid: postCid,
#direction: direction,
}),
+
returnValue: _i6.Future<bool>.value(false),
)
+
as _i6.Future<bool>);
@override
void setInitialVoteState({
···
);
@override
+
void addListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
+
void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
returnValueForMissingStub: null,
);
}
+
+
/// A class which mocks [CommentService].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockCommentService extends _i1.Mock implements _i4.CommentService {
+
MockCommentService() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i6.Future<_i4.CreateCommentResponse> createComment({
+
required String? rootUri,
+
required String? rootCid,
+
required String? parentUri,
+
required String? parentCid,
+
required String? content,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#createComment, [], {
+
#rootUri: rootUri,
+
#rootCid: rootCid,
+
#parentUri: parentUri,
+
#parentCid: parentCid,
+
#content: content,
+
}),
+
returnValue: _i6.Future<_i4.CreateCommentResponse>.value(
+
_FakeCreateCommentResponse_2(
+
this,
+
Invocation.method(#createComment, [], {
+
#rootUri: rootUri,
+
#rootCid: rootCid,
+
#parentUri: parentUri,
+
#parentCid: parentCid,
+
#content: content,
+
}),
+
),
+
),
+
)
+
as _i6.Future<_i4.CreateCommentResponse>);
+
}
+357
test/services/comment_service_test.dart
···
···
+
import 'package:coves_flutter/models/coves_session.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/comment_service.dart';
+
import 'package:dio/dio.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:mockito/annotations.dart';
+
import 'package:mockito/mockito.dart';
+
+
import 'comment_service_test.mocks.dart';
+
+
@GenerateMocks([Dio])
+
void main() {
+
group('CommentService', () {
+
group('CreateCommentResponse', () {
+
test('should create response with uri and cid', () {
+
const response = CreateCommentResponse(
+
uri: 'at://did:plc:test/social.coves.community.comment/123',
+
cid: 'bafy123',
+
);
+
+
expect(
+
response.uri,
+
'at://did:plc:test/social.coves.community.comment/123',
+
);
+
expect(response.cid, 'bafy123');
+
});
+
});
+
+
group('createComment', () {
+
late MockDio mockDio;
+
late CommentService commentService;
+
late CovesSession testSession;
+
+
setUp(() {
+
mockDio = MockDio();
+
testSession = CovesSession(
+
token: 'test-token',
+
did: 'did:plc:test',
+
sessionId: 'test-session-id',
+
handle: 'test.user',
+
);
+
+
// Setup default interceptors behavior
+
when(mockDio.interceptors).thenReturn(Interceptors());
+
+
commentService = CommentService(
+
sessionGetter: () async => testSession,
+
tokenRefresher: () async => true,
+
signOutHandler: () async {},
+
dio: mockDio,
+
);
+
});
+
+
test('should create comment successfully', () 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': 'at://did:plc:test/social.coves.community.comment/abc123',
+
'cid': 'bafy123',
+
},
+
),
+
);
+
+
final response = await commentService.createComment(
+
rootUri: 'at://did:plc:author/social.coves.post.record/post123',
+
rootCid: 'rootCid123',
+
parentUri: 'at://did:plc:author/social.coves.post.record/post123',
+
parentCid: 'parentCid123',
+
content: 'This is a test comment',
+
);
+
+
expect(
+
response.uri,
+
'at://did:plc:test/social.coves.community.comment/abc123',
+
);
+
expect(response.cid, 'bafy123');
+
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'rootCid123',
+
},
+
'parent': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'parentCid123',
+
},
+
},
+
'content': 'This is a test comment',
+
},
+
),
+
).called(1);
+
});
+
+
test('should throw AuthenticationException when no session', () async {
+
final serviceWithoutSession = CommentService(
+
sessionGetter: () async => null,
+
tokenRefresher: () async => true,
+
signOutHandler: () async {},
+
dio: mockDio,
+
);
+
+
expect(
+
() => serviceWithoutSession.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should throw ApiException on network error', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.connectionError,
+
message: 'Connection failed',
+
),
+
);
+
+
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>()),
+
);
+
});
+
+
test('should throw AuthenticationException on 401 response', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 401,
+
data: {'error': 'Unauthorized'},
+
),
+
),
+
);
+
+
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<AuthenticationException>()),
+
);
+
});
+
+
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'),
+
),
+
),
+
);
+
});
+
+
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'),
+
),
+
),
+
);
+
});
+
+
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'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on server error', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 500,
+
data: {'error': 'Internal server error'},
+
),
+
message: 'Internal server error',
+
),
+
);
+
+
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>()),
+
);
+
});
+
+
test('should send correct parent for nested reply', () 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': 'at://did:plc:test/social.coves.community.comment/reply1',
+
'cid': 'bafyReply',
+
},
+
),
+
);
+
+
await commentService.createComment(
+
rootUri: 'at://did:plc:author/social.coves.post.record/post123',
+
rootCid: 'postCid',
+
parentUri:
+
'at://did:plc:commenter/social.coves.community.comment/comment1',
+
parentCid: 'commentCid',
+
content: 'This is a nested reply',
+
);
+
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'postCid',
+
},
+
'parent': {
+
'uri':
+
'at://did:plc:commenter/social.coves.community.comment/'
+
'comment1',
+
'cid': 'commentCid',
+
},
+
},
+
'content': 'This is a nested reply',
+
},
+
),
+
).called(1);
+
});
+
});
+
});
+
}
+806
test/services/comment_service_test.mocks.dart
···
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/comment_service_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i8;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i9;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i8.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i9.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i8.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i9.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i9.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i9.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}
+42 -5
lib/providers/feed_provider.dart
···
import 'auth_provider.dart';
import 'vote_provider.dart';
/// Feed Provider
///
/// Manages feed state and fetching logic.
···
if (kDebugMode) {
debugPrint('๐Ÿ”’ User signed out - clearing feed');
}
reset();
// Automatically load the public discover feed
loadFeed(refresh: true);
···
// Feed configuration
String _sort = 'hot';
String? _timeframe;
// Time update mechanism for periodic UI refreshes
Timer? _timeUpdateTimer;
···
String get sort => _sort;
String? get timeframe => _timeframe;
DateTime? get currentTime => _currentTime;
/// Start periodic time updates for "time ago" strings
///
···
}
}
-
/// Load feed based on authentication state (business logic
-
/// encapsulation)
///
/// This method encapsulates the business logic of deciding which feed
-
/// to fetch. Previously this logic was in the UI layer (FeedScreen),
-
/// violating clean architecture.
Future<void> loadFeed({bool refresh = false}) async {
-
if (_authProvider.isAuthenticated) {
await fetchTimeline(refresh: refresh);
} else {
await fetchDiscover(refresh: refresh);
···
}
}
/// Common feed fetching logic (DRY principle - eliminates code
/// duplication)
Future<void> _fetchFeed({
···
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.
···
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);
···
// Feed configuration
String _sort = 'hot';
String? _timeframe;
+
FeedType _feedType = FeedType.discover;
// Time update mechanism for periodic UI refreshes
Timer? _timeUpdateTimer;
···
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
///
···
}
}
+
/// 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);
···
}
}
+
/// 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({
+12 -8
lib/screens/home/search_screen.dart lib/screens/home/communities_screen.dart
···
import '../../constants/app_colors.dart';
-
class SearchScreen extends StatelessWidget {
-
const SearchScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
-
backgroundColor: const Color(0xFF0B0F14),
appBar: AppBar(
-
backgroundColor: const Color(0xFF0B0F14),
foregroundColor: Colors.white,
-
title: const Text('Search'),
automaticallyImplyLeading: false,
),
body: const Center(
···
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
-
Icon(Icons.search, size: 64, color: AppColors.primary),
SizedBox(height: 24),
Text(
-
'Search',
style: TextStyle(
fontSize: 28,
color: Colors.white,
···
),
SizedBox(height: 16),
Text(
-
'Search communities and conversations',
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
textAlign: TextAlign.center,
),
···
import '../../constants/app_colors.dart';
+
class CommunitiesScreen extends StatelessWidget {
+
const CommunitiesScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
+
backgroundColor: AppColors.background,
appBar: AppBar(
+
backgroundColor: AppColors.background,
foregroundColor: Colors.white,
+
title: const Text('Communities'),
automaticallyImplyLeading: false,
),
body: const Center(
···
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
+
Icon(
+
Icons.workspaces_outlined,
+
size: 64,
+
color: AppColors.primary,
+
),
SizedBox(height: 24),
Text(
+
'Communities',
style: TextStyle(
fontSize: 28,
color: Colors.white,
···
),
SizedBox(height: 16),
Text(
+
'Discover and join communities',
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
textAlign: TextAlign.center,
),
+162 -14
lib/screens/home/feed_screen.dart
···
import '../../models/post.dart';
import '../../providers/auth_provider.dart';
import '../../providers/feed_provider.dart';
import '../../widgets/post_card.dart';
class FeedScreen extends StatefulWidget {
-
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
···
);
final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading);
final error = context.select<FeedProvider, String?>((p) => p.error);
// IMPORTANT: This relies on FeedProvider creating new list instances
// (_posts = [..._posts, ...response.feed]) rather than mutating in-place.
···
return Scaffold(
backgroundColor: AppColors.background,
-
appBar: AppBar(
-
backgroundColor: AppColors.background,
-
foregroundColor: AppColors.textPrimary,
-
title: Text(isAuthenticated ? 'Feed' : 'Explore'),
-
automaticallyImplyLeading: false,
-
),
body: SafeArea(
-
child: _buildBody(
-
isLoading: isLoading,
-
error: error,
-
posts: posts,
-
isLoadingMore: isLoadingMore,
-
isAuthenticated: isAuthenticated,
-
currentTime: currentTime,
),
),
);
}
Widget _buildBody({
required bool isLoading,
required String? error,
···
color: AppColors.primary,
child: ListView.builder(
controller: _scrollController,
// Add extra item for loading indicator or pagination error
itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0),
itemBuilder: (context, index) {
···
import '../../models/post.dart';
import '../../providers/auth_provider.dart';
import '../../providers/feed_provider.dart';
+
import '../../widgets/icons/bluesky_icons.dart';
import '../../widgets/post_card.dart';
+
/// Header layout constants
+
const double _kHeaderHeight = 44;
+
const double _kTabUnderlineWidth = 28;
+
const double _kTabUnderlineHeight = 3;
+
const double _kHeaderContentPadding = _kHeaderHeight;
+
class FeedScreen extends StatefulWidget {
+
const FeedScreen({super.key, this.onSearchTap});
+
+
/// Callback when search icon is tapped (to switch to communities tab)
+
final VoidCallback? onSearchTap;
@override
State<FeedScreen> createState() => _FeedScreenState();
···
);
final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading);
final error = context.select<FeedProvider, String?>((p) => p.error);
+
final feedType = context.select<FeedProvider, FeedType>(
+
(p) => p.feedType,
+
);
// IMPORTANT: This relies on FeedProvider creating new list instances
// (_posts = [..._posts, ...response.feed]) rather than mutating in-place.
···
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
+
child: Stack(
+
children: [
+
// Feed content (behind header)
+
_buildBody(
+
isLoading: isLoading,
+
error: error,
+
posts: posts,
+
isLoadingMore: isLoadingMore,
+
isAuthenticated: isAuthenticated,
+
currentTime: currentTime,
+
),
+
// Transparent header overlay
+
_buildHeader(
+
feedType: feedType,
+
isAuthenticated: isAuthenticated,
+
),
+
],
+
),
+
),
+
);
+
}
+
+
Widget _buildHeader({
+
required FeedType feedType,
+
required bool isAuthenticated,
+
}) {
+
return Container(
+
height: _kHeaderHeight,
+
decoration: BoxDecoration(
+
// Gradient fade from solid to transparent
+
gradient: LinearGradient(
+
begin: Alignment.topCenter,
+
end: Alignment.bottomCenter,
+
colors: [
+
AppColors.background,
+
AppColors.background.withValues(alpha: 0.8),
+
AppColors.background.withValues(alpha: 0),
+
],
+
stops: const [0.0, 0.6, 1.0],
+
),
+
),
+
padding: const EdgeInsets.symmetric(horizontal: 16),
+
child: Row(
+
children: [
+
// Feed type tabs in the center
+
Expanded(
+
child: _buildFeedTypeTabs(
+
feedType: feedType,
+
isAuthenticated: isAuthenticated,
+
),
+
),
+
// Search/Communities icon on the right
+
if (widget.onSearchTap != null)
+
Semantics(
+
label: 'Navigate to Communities',
+
button: true,
+
child: InkWell(
+
onTap: widget.onSearchTap,
+
borderRadius: BorderRadius.circular(20),
+
splashColor: AppColors.primary.withValues(alpha: 0.2),
+
child: Padding(
+
padding: const EdgeInsets.all(8),
+
child: BlueSkyIcon.search(color: AppColors.textPrimary),
+
),
+
),
+
),
+
],
+
),
+
);
+
}
+
+
Widget _buildFeedTypeTabs({
+
required FeedType feedType,
+
required bool isAuthenticated,
+
}) {
+
// If not authenticated, only show Discover
+
if (!isAuthenticated) {
+
return Center(
+
child: _buildFeedTypeTab(
+
label: 'Discover',
+
isActive: true,
+
onTap: null,
+
),
+
);
+
}
+
+
// Authenticated: show both tabs side by side (TikTok style)
+
return Row(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
_buildFeedTypeTab(
+
label: 'Discover',
+
isActive: feedType == FeedType.discover,
+
onTap: () => _switchToFeedType(FeedType.discover),
+
),
+
const SizedBox(width: 24),
+
_buildFeedTypeTab(
+
label: 'For You',
+
isActive: feedType == FeedType.forYou,
+
onTap: () => _switchToFeedType(FeedType.forYou),
+
),
+
],
+
);
+
}
+
+
Widget _buildFeedTypeTab({
+
required String label,
+
required bool isActive,
+
required VoidCallback? onTap,
+
}) {
+
return Semantics(
+
label: '$label feed${isActive ? ', selected' : ''}',
+
button: true,
+
selected: isActive,
+
child: GestureDetector(
+
onTap: onTap,
+
behavior: HitTestBehavior.opaque,
+
child: Column(
+
mainAxisSize: MainAxisSize.min,
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
Text(
+
label,
+
style: TextStyle(
+
color: isActive
+
? AppColors.textPrimary
+
: AppColors.textSecondary.withValues(alpha: 0.6),
+
fontSize: 16,
+
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
+
),
+
),
+
const SizedBox(height: 2),
+
// Underline indicator (TikTok style)
+
Container(
+
width: _kTabUnderlineWidth,
+
height: _kTabUnderlineHeight,
+
decoration: BoxDecoration(
+
color: isActive ? AppColors.textPrimary : Colors.transparent,
+
borderRadius: BorderRadius.circular(2),
+
),
+
),
+
],
),
),
);
}
+
void _switchToFeedType(FeedType type) {
+
Provider.of<FeedProvider>(context, listen: false).setFeedType(type);
+
}
+
Widget _buildBody({
required bool isLoading,
required String? error,
···
color: AppColors.primary,
child: ListView.builder(
controller: _scrollController,
+
// Add top padding so content isn't hidden behind transparent header
+
padding: const EdgeInsets.only(top: _kHeaderContentPadding),
// Add extra item for loading indicator or pagination error
itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0),
itemBuilder: (context, index) {
+24 -13
lib/screens/home/main_shell_screen.dart
···
import '../../constants/app_colors.dart';
import '../../widgets/icons/bluesky_icons.dart';
import 'create_post_screen.dart';
import 'feed_screen.dart';
import 'notifications_screen.dart';
import 'profile_screen.dart';
-
import 'search_screen.dart';
class MainShellScreen extends StatefulWidget {
const MainShellScreen({super.key});
···
class _MainShellScreenState extends State<MainShellScreen> {
int _selectedIndex = 0;
-
static const List<Widget> _screens = [
-
FeedScreen(),
-
SearchScreen(),
-
CreatePostScreen(),
-
NotificationsScreen(),
-
ProfileScreen(),
-
];
-
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
-
body: _screens[_selectedIndex],
bottomNavigationBar: Container(
decoration: const BoxDecoration(
color: Color(0xFF0B0F14),
···
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, 'home', 'Home'),
-
_buildNavItem(1, 'search', 'Search'),
_buildNavItem(2, 'plus', 'Create'),
_buildNavItem(3, 'bell', 'Notifications'),
_buildNavItem(4, 'person', 'Me'),
···
case 'home':
icon = BlueSkyIcon.homeSimple(color: color);
break;
-
case 'search':
-
icon = BlueSkyIcon.search(color: color);
break;
case 'plus':
icon = BlueSkyIcon.plus(color: color);
···
import '../../constants/app_colors.dart';
import '../../widgets/icons/bluesky_icons.dart';
+
import 'communities_screen.dart';
import 'create_post_screen.dart';
import 'feed_screen.dart';
import 'notifications_screen.dart';
import 'profile_screen.dart';
class MainShellScreen extends StatefulWidget {
const MainShellScreen({super.key});
···
class _MainShellScreenState extends State<MainShellScreen> {
int _selectedIndex = 0;
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
+
void _onCommunitiesTap() {
+
setState(() {
+
_selectedIndex = 1; // Switch to communities tab
+
});
+
}
+
@override
Widget build(BuildContext context) {
return Scaffold(
+
body: IndexedStack(
+
index: _selectedIndex,
+
children: [
+
FeedScreen(onSearchTap: _onCommunitiesTap),
+
const CommunitiesScreen(),
+
const CreatePostScreen(),
+
const NotificationsScreen(),
+
const ProfileScreen(),
+
],
+
),
bottomNavigationBar: Container(
decoration: const BoxDecoration(
color: Color(0xFF0B0F14),
···
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, 'home', 'Home'),
+
_buildNavItem(1, 'communities', 'Communities'),
_buildNavItem(2, 'plus', 'Create'),
_buildNavItem(3, 'bell', 'Notifications'),
_buildNavItem(4, 'person', 'Me'),
···
case 'home':
icon = BlueSkyIcon.homeSimple(color: color);
break;
+
case 'communities':
+
icon = Icon(
+
isSelected ? Icons.workspaces : Icons.workspaces_outlined,
+
color: color,
+
size: 24,
+
);
break;
case 'plus':
icon = BlueSkyIcon.plus(color: color);
+3
.gitignore
···
/android/app/debug
/android/app/profile
/android/app/release
···
/android/app/debug
/android/app/profile
/android/app/release
+
+
# macOS (not targeting this platform)
+
macos/
+1
ios/Flutter/Debug.xcconfig
···
#include "Generated.xcconfig"
···
+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
ios/Flutter/Release.xcconfig
···
#include "Generated.xcconfig"
···
+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
ios/Podfile
···
···
+
# Uncomment this line to define a global platform for your project
+
# platform :ios, '13.0'
+
+
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+
project 'Runner', {
+
'Debug' => :debug,
+
'Profile' => :release,
+
'Release' => :release,
+
}
+
+
def flutter_root
+
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+
unless File.exist?(generated_xcode_build_settings_path)
+
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+
end
+
+
File.foreach(generated_xcode_build_settings_path) do |line|
+
matches = line.match(/FLUTTER_ROOT\=(.*)/)
+
return matches[1].strip if matches
+
end
+
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+
end
+
+
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+
flutter_ios_podfile_setup
+
+
target 'Runner' do
+
use_frameworks!
+
+
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+
target 'RunnerTests' do
+
inherit! :search_paths
+
end
+
end
+
+
post_install do |installer|
+
installer.pods_project.targets.each do |target|
+
flutter_additional_ios_build_settings(target)
+
end
+
end
+4 -4
pubspec.lock
···
dependency: transitive
description:
name: meta
-
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
-
version: "1.16.0"
mime:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
-
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
-
version: "0.7.6"
typed_data:
dependency: transitive
description:
···
dependency: transitive
description:
name: meta
+
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
+
version: "1.17.0"
mime:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
+
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
+
version: "0.7.7"
typed_data:
dependency: transitive
description:
+1 -1
ios/Flutter/AppFrameworkInfo.plist
···
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
-
<string>12.0</string>
</dict>
</plist>
···
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
+
<string>13.0</string>
</dict>
</plist>
+68
ios/Podfile.lock
···
···
+
PODS:
+
- Flutter (1.0.0)
+
- flutter_secure_storage (6.0.0):
+
- Flutter
+
- flutter_web_auth_2 (3.0.0):
+
- Flutter
+
- path_provider_foundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
- share_plus (0.0.1):
+
- Flutter
+
- shared_preferences_foundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
- sqflite_darwin (0.0.4):
+
- Flutter
+
- FlutterMacOS
+
- url_launcher_ios (0.0.1):
+
- Flutter
+
- video_player_avfoundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
+
DEPENDENCIES:
+
- Flutter (from `Flutter`)
+
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
+
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
+
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+
- share_plus (from `.symlinks/plugins/share_plus/ios`)
+
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
+
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
+
+
EXTERNAL SOURCES:
+
Flutter:
+
:path: Flutter
+
flutter_secure_storage:
+
:path: ".symlinks/plugins/flutter_secure_storage/ios"
+
flutter_web_auth_2:
+
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
+
path_provider_foundation:
+
:path: ".symlinks/plugins/path_provider_foundation/darwin"
+
share_plus:
+
:path: ".symlinks/plugins/share_plus/ios"
+
shared_preferences_foundation:
+
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+
sqflite_darwin:
+
:path: ".symlinks/plugins/sqflite_darwin/darwin"
+
url_launcher_ios:
+
:path: ".symlinks/plugins/url_launcher_ios/ios"
+
video_player_avfoundation:
+
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
+
+
SPEC CHECKSUMS:
+
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
+
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
+
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
+
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
+
+
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+
+
COCOAPODS: 1.16.2
+115 -3
ios/Runner.xcodeproj/project.pbxproj
···
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
···
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
···
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F2B3C8D12D0C8A5E00ABCDEF /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
···
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
···
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
···
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
···
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
···
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
···
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
···
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
···
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
-
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
···
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
···
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
···
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
···
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
-
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
···
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
-
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
···
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+
16E67738C4AF07C35AA47470 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 82EC9CF23352AC72F2003AAD /* Pods_RunnerTests.framework */; };
+
2220618238061C279E522B7E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2A2DB00FCDBEA05F362717D /* Pods_Runner.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
···
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
+
24C909BB605D55AC18D4D709 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
+
58C1A39422F3ADDA7073882C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
+
62C533E7959427EBD54BF4E0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
+
7404320A2A2665D2993CC4A9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
+
82EC9CF23352AC72F2003AAD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+
91248B6140D65FC329BE4089 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
···
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+
CAFF337A6DF135B15E2E5A82 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
+
D2A2DB00FCDBEA05F362717D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F2B3C8D12D0C8A5E00ABCDEF /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
+
6A654D0E96DDFAB5016AAB44 /* Frameworks */ = {
+
isa = PBXFrameworksBuildPhase;
+
buildActionMask = 2147483647;
+
files = (
+
16E67738C4AF07C35AA47470 /* Pods_RunnerTests.framework in Frameworks */,
+
);
+
runOnlyForDeploymentPostprocessing = 0;
+
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+
2220618238061C279E522B7E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
···
path = RunnerTests;
sourceTree = "<group>";
};
+
86A9EDA55647EB05647C404F /* Frameworks */ = {
+
isa = PBXGroup;
+
children = (
+
D2A2DB00FCDBEA05F362717D /* Pods_Runner.framework */,
+
82EC9CF23352AC72F2003AAD /* Pods_RunnerTests.framework */,
+
);
+
name = Frameworks;
+
sourceTree = "<group>";
+
};
+
8AC347B174FB51D9D1783044 /* Pods */ = {
+
isa = PBXGroup;
+
children = (
+
91248B6140D65FC329BE4089 /* Pods-Runner.debug.xcconfig */,
+
7404320A2A2665D2993CC4A9 /* Pods-Runner.release.xcconfig */,
+
62C533E7959427EBD54BF4E0 /* Pods-Runner.profile.xcconfig */,
+
24C909BB605D55AC18D4D709 /* Pods-RunnerTests.debug.xcconfig */,
+
58C1A39422F3ADDA7073882C /* Pods-RunnerTests.release.xcconfig */,
+
CAFF337A6DF135B15E2E5A82 /* Pods-RunnerTests.profile.xcconfig */,
+
);
+
name = Pods;
+
path = Pods;
+
sourceTree = "<group>";
+
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
···
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
+
8AC347B174FB51D9D1783044 /* Pods */,
+
86A9EDA55647EB05647C404F /* Frameworks */,
);
sourceTree = "<group>";
};
···
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
+
0D16B9D95FB392A9811278BE /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
+
6A654D0E96DDFAB5016AAB44 /* Frameworks */,
);
buildRules = (
);
···
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
+
5D065FE9468A69BB975A017A /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+
A11CDD673B8A553D9BF96957 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
···
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
+
0D16B9D95FB392A9811278BE /* [CP] Check Pods Manifest.lock */ = {
+
isa = PBXShellScriptBuildPhase;
+
buildActionMask = 2147483647;
+
files = (
+
);
+
inputFileListPaths = (
+
);
+
inputPaths = (
+
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+
"${PODS_ROOT}/Manifest.lock",
+
);
+
name = "[CP] Check Pods Manifest.lock";
+
outputFileListPaths = (
+
);
+
outputPaths = (
+
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+
);
+
runOnlyForDeploymentPostprocessing = 0;
+
shellPath = /bin/sh;
+
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+
showEnvVarsInLog = 0;
+
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
···
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
+
5D065FE9468A69BB975A017A /* [CP] Check Pods Manifest.lock */ = {
+
isa = PBXShellScriptBuildPhase;
+
buildActionMask = 2147483647;
+
files = (
+
);
+
inputFileListPaths = (
+
);
+
inputPaths = (
+
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+
"${PODS_ROOT}/Manifest.lock",
+
);
+
name = "[CP] Check Pods Manifest.lock";
+
outputFileListPaths = (
+
);
+
outputPaths = (
+
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+
);
+
runOnlyForDeploymentPostprocessing = 0;
+
shellPath = /bin/sh;
+
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+
showEnvVarsInLog = 0;
+
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
···
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
+
A11CDD673B8A553D9BF96957 /* [CP] Embed Pods Frameworks */ = {
+
isa = PBXShellScriptBuildPhase;
+
buildActionMask = 2147483647;
+
files = (
+
);
+
inputFileListPaths = (
+
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+
);
+
name = "[CP] Embed Pods Frameworks";
+
outputFileListPaths = (
+
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+
);
+
runOnlyForDeploymentPostprocessing = 0;
+
shellPath = /bin/sh;
+
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+
showEnvVarsInLog = 0;
+
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
···
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
···
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
+
baseConfigurationReference = 24C909BB605D55AC18D4D709 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
···
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
+
baseConfigurationReference = 58C1A39422F3ADDA7073882C /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
···
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
+
baseConfigurationReference = CAFF337A6DF135B15E2E5A82 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
···
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
···
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+2
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
+3
ios/Runner.xcworkspace/contents.xcworkspacedata
···
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
···
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
+
<FileRef
+
location = "group:Pods/Pods.xcodeproj">
+
</FileRef>
</Workspace>