diff --git a/docs/specs/phase-2.md b/docs/specs/phase-2.md
index 88d2d16..60f8b6b 100644
--- a/docs/specs/phase-2.md
+++ b/docs/specs/phase-2.md
@@ -1,5 +1,85 @@
# Lazurite Phase 2 Spec
+## Logging
+
+Structured logging for development debugging and in-app log inspection. Uses
+the [`logger`](https://pub.dev/packages/logger) package (v2.x) — the most
+widely adopted Flutter logging library — with file-based persistence via its
+built-in `AdvancedFileOutput`.
+
+### Log Levels
+
+| Level | Usage |
+| --------- | ------------------------------------------- |
+| `trace` | Fine-grained control flow (loop iterations) |
+| `debug` | Development-only diagnostics |
+| `info` | Significant lifecycle events (login, nav) |
+| `warning` | Recoverable issues (retry, fallback) |
+| `error` | Failures with stack traces |
+| `fatal` | Unrecoverable errors (crash-level) |
+
+### Architecture
+
+A single `AppLogger` wrapper class exposes a top-level `log` instance injected
+via the service locator. All subsystems log through this instance.
+
+```sh
+AppLogger
+├── LogFilter (DevelopmentFilter / ProductionFilter)
+├── LogPrinter (PrettyPrinter for dev, SimplePrinter for file)
+└── LogOutput
+ ├── ConsoleOutput (always, dev only)
+ └── AdvancedFileOutput (always, all builds)
+```
+
+**Console output** — enabled only in debug builds via `DevelopmentFilter`.
+Uses `PrettyPrinter` with method counts, colors, and emojis for readability
+in the terminal.
+
+**File output** — enabled in all builds. Uses `AdvancedFileOutput` which
+writes to the app's documents directory (`getApplicationDocumentsDirectory()`).
+Files are rotated daily with a configurable retention window (default 3 days).
+File format: `lazurite_YYYY-MM-DD.log`, one line per event using
+`SimplePrinter(colors: false)`.
+
+### Integration Points
+
+| Subsystem | What gets logged |
+| --------- | ----------------------------------------------- |
+| BLoC | State transitions via `BlocObserver` override |
+| HTTP | Request/response summaries (no auth headers) |
+| Auth | OAuth flow steps, token refresh, session events |
+| Nav | Route changes via `NavigatorObserver` |
+| DB | Drift query errors |
+
+**Security:** Never log access tokens, refresh tokens, passwords, or full
+request/response bodies. HTTP logging redacts the `Authorization` header and
+truncates bodies to 200 chars.
+
+### In-App Log Viewer
+
+Accessible via Settings → Dev Tools → Logs. Reads log files from disk and
+displays entries in a scrollable, filterable list.
+
+**Features:**
+
+1. **Level filter** — chip bar to toggle visibility per level
+2. **Search** — free-text filter across log messages
+3. **Auto-scroll** — locks to bottom for live tailing; unlocks on manual scroll
+4. **Share** — export current day's log file via the system share sheet
+5. **Clear** — delete all log files with confirmation
+
+Each log entry renders as a single row:
+
+| Element | Format |
+| --------- | ----------------------------------- |
+| Timestamp | `HH:mm:ss.SSS` in monospace |
+| Level | Colored badge (E / W / I / D) |
+| Message | Truncated to 2 lines, tap to expand |
+
+No Bloc needed — use a `LogViewerCubit` with simple file-read state, since
+this is a stateless inspection tool.
+
## Feeds
Phase 1 builds profile author feeds only. Phase 2 adds the full home feed
diff --git a/docs/tasks/phase-1.md b/docs/tasks/phase-1.md
index 6563679..a3bfb5f 100644
--- a/docs/tasks/phase-1.md
+++ b/docs/tasks/phase-1.md
@@ -19,11 +19,11 @@
## M2 — Profile Rendering
-- [ ] Build `ProfileBloc` — fetch via `getProfile` / `getProfiles`
-- [ ] Profile screen: avatar, banner, display name, handle, description, stats (followers/following/posts), pronouns, website
-- [ ] Build `FeedBloc` — paginated fetch via `getAuthorFeed` with cursor + filter support
-- [ ] Post card widget: text, timestamps, embeds (images, quote posts, link cards)
-- [ ] Facet rendering: parse via `bluesky_text`, render mentions / links / hashtags as tappable spans (UTF-8 byte-safe)
+- [x] Build `ProfileBloc` — fetch via `getProfile` / `getProfiles`
+- [x] Profile screen: avatar, banner, display name, handle, description, stats (followers/following/posts), pronouns, website
+- [x] Build `FeedBloc` — paginated fetch via `getAuthorFeed` with cursor + filter support
+- [x] Post card widget: text, timestamps, embeds (images, quote posts, link cards)
+- [x] Facet rendering: parse via `bluesky_text`, render mentions / links / hashtags as tappable spans (UTF-8 byte-safe)
## M3 — Settings & Theming
diff --git a/docs/tasks/phase-2.md b/docs/tasks/phase-2.md
index 27adc50..c493468 100644
--- a/docs/tasks/phase-2.md
+++ b/docs/tasks/phase-2.md
@@ -1,6 +1,21 @@
# Phase 2 Milestones
-## M5 — Feeds
+## M5 — Logging
+
+- [ ] Add `logger` package dependency
+- [ ] `AppLogger` wrapper — singleton with `DevelopmentFilter` + `PrettyPrinter` for console, `AdvancedFileOutput` + `SimplePrinter` for file
+- [ ] File rotation — daily log files in app documents dir (`lazurite_YYYY-MM-DD.log`), 3-day retention
+- [ ] `LoggingBlocObserver` — log BLoC state transitions at `debug` level
+- [ ] HTTP logging interceptor — request/response summaries, redact `Authorization` header, truncate bodies
+- [ ] `NavigatorObserver` subclass — log route changes at `info` level
+- [ ] Log viewer screen — scrollable list reading from log files on disk
+- [ ] Level filter chip bar — toggle visibility per log level
+- [ ] Free-text search across log messages
+- [ ] Share button — export current day's log file via system share sheet
+- [ ] Clear all logs with confirmation dialog
+- [ ] Add "Logs" entry under Dev Tools in Settings screen
+
+## M6 — Feeds
- [ ] Build home screen with horizontally-swipable tab bar (one tab per pinned feed)
- [ ] Implement timeline feed via `getTimeline` with cursor pagination
@@ -10,7 +25,7 @@
- [ ] Feed discovery screen via `getSuggestedFeeds` — browse and add generators
- [ ] Feed management UI — pin/unpin, drag-to-reorder, remove saved feeds
-## M6 — Search
+## M7 — Search
- [ ] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors)
- [ ] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted`
@@ -20,7 +35,7 @@
- [ ] Drift migration: add `search_history` table (query, type, searched_at, account_did)
- [ ] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account
-## M7 — Dev Tools (PDS Explorer)
+## M8 — Dev Tools (PDS Explorer)
- [ ] `DevToolsCubit` with request/response state for stateless exploration
- [ ] Handle / DID input with resolution via `resolveHandle`
diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart
index f84d874..aafbe5f 100644
--- a/lib/core/router/app_router.dart
+++ b/lib/core/router/app_router.dart
@@ -31,7 +31,10 @@ class AppRouter {
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
- GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()),
+ GoRoute(
+ path: '/profile',
+ builder: (context, state) => ProfileScreen(actor: state.uri.queryParameters['actor']),
+ ),
GoRoute(path: '/settings', builder: (context, state) => const SettingsScreen()),
],
);
diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart
index d58ab99..e7fba7a 100644
--- a/lib/features/auth/data/auth_repository.dart
+++ b/lib/features/auth/data/auth_repository.dart
@@ -287,14 +287,14 @@ class AuthRepository {
final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession();
resolvedHandle = authSession.data.handle;
} catch (_) {
- // Fall back to the login hint if the server session lookup fails.
+ // TODO: log this -> Fall back to the login hint if the server session lookup fails.
}
try {
final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub);
displayName = profile.data.displayName;
} catch (_) {
- // Display name is optional and should not block session persistence.
+ // TODO: log this -> Display name is optional and should not block session persistence.
}
return AuthTokens(
diff --git a/lib/features/feed/bloc/feed_bloc.dart b/lib/features/feed/bloc/feed_bloc.dart
new file mode 100644
index 0000000..35d7e53
--- /dev/null
+++ b/lib/features/feed/bloc/feed_bloc.dart
@@ -0,0 +1,87 @@
+import 'package:bluesky/app_bsky_feed_defs.dart';
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:lazurite/features/feed/data/feed_repository.dart';
+
+export 'package:lazurite/features/feed/data/feed_repository.dart' show FeedFilter;
+
+part 'feed_event.dart';
+part 'feed_state.dart';
+
+class FeedBloc extends Bloc
{
+ FeedBloc({required FeedRepository feedRepository})
+ : _feedRepository = feedRepository,
+ super(const FeedState.initial()) {
+ on(_onFeedLoadRequested);
+ on(_onFeedLoadMoreRequested);
+ on(_onFeedRefreshRequested);
+ }
+
+ final FeedRepository _feedRepository;
+
+ Future _onFeedLoadRequested(FeedLoadRequested event, Emitter emit) async {
+ emit(FeedState.loading(actor: event.actor, filter: event.filter));
+
+ try {
+ final result = await _feedRepository.getAuthorFeed(actor: event.actor, filter: event.filter, limit: event.limit);
+
+ emit(
+ FeedState.loaded(
+ actor: event.actor,
+ posts: result.posts,
+ cursor: result.cursor,
+ filter: event.filter,
+ hasMore: result.cursor != null,
+ ),
+ );
+ } catch (error) {
+ emit(FeedState.error('Failed to load feed: $error'));
+ }
+ }
+
+ Future _onFeedLoadMoreRequested(FeedLoadMoreRequested event, Emitter emit) async {
+ if (state.status != FeedStatus.loaded || state.actor == null || state.cursor == null || state.isLoadingMore) {
+ return;
+ }
+
+ emit(state.copyWith(isLoadingMore: true));
+
+ try {
+ final result = await _feedRepository.getAuthorFeed(
+ actor: state.actor!,
+ filter: state.filter,
+ cursor: state.cursor,
+ limit: event.limit,
+ );
+
+ emit(
+ state.copyWith(
+ posts: [...state.posts, ...result.posts],
+ cursor: result.cursor,
+ hasMore: result.cursor != null,
+ isLoadingMore: false,
+ ),
+ );
+ } catch (error) {
+ emit(state.copyWith(isLoadingMore: false, hasMore: false));
+ }
+ }
+
+ Future _onFeedRefreshRequested(FeedRefreshRequested event, Emitter emit) async {
+ if (state.status != FeedStatus.loaded || state.actor == null) {
+ return;
+ }
+
+ emit(state.copyWith(isRefreshing: true));
+
+ try {
+ final result = await _feedRepository.getAuthorFeed(actor: state.actor!, filter: state.filter, limit: 50);
+
+ emit(
+ state.copyWith(posts: result.posts, cursor: result.cursor, hasMore: result.cursor != null, isRefreshing: false),
+ );
+ } catch (error) {
+ emit(state.copyWith(isRefreshing: false));
+ }
+ }
+}
diff --git a/lib/features/feed/bloc/feed_event.dart b/lib/features/feed/bloc/feed_event.dart
new file mode 100644
index 0000000..ff996c8
--- /dev/null
+++ b/lib/features/feed/bloc/feed_event.dart
@@ -0,0 +1,30 @@
+part of 'feed_bloc.dart';
+
+abstract class FeedEvent extends Equatable {
+ const FeedEvent();
+
+ @override
+ List get props => [];
+}
+
+class FeedLoadRequested extends FeedEvent {
+ const FeedLoadRequested({required this.actor, this.filter = FeedFilter.postsAndAuthorThreads, this.limit = 50});
+ final String actor;
+ final FeedFilter filter;
+ final int limit;
+
+ @override
+ List get props => [actor, filter, limit];
+}
+
+class FeedLoadMoreRequested extends FeedEvent {
+ const FeedLoadMoreRequested({this.limit = 50});
+ final int limit;
+
+ @override
+ List get props => [limit];
+}
+
+class FeedRefreshRequested extends FeedEvent {
+ const FeedRefreshRequested();
+}
diff --git a/lib/features/feed/bloc/feed_state.dart b/lib/features/feed/bloc/feed_state.dart
new file mode 100644
index 0000000..93cc56c
--- /dev/null
+++ b/lib/features/feed/bloc/feed_state.dart
@@ -0,0 +1,73 @@
+part of 'feed_bloc.dart';
+
+enum FeedStatus { initial, loading, loaded, error }
+
+class FeedState extends Equatable {
+ const FeedState._({
+ required this.status,
+ this.actor,
+ this.posts = const [],
+ this.cursor,
+ this.errorMessage,
+ this.filter = FeedFilter.postsAndAuthorThreads,
+ this.hasMore = true,
+ this.isLoadingMore = false,
+ this.isRefreshing = false,
+ });
+
+ const FeedState.initial() : this._(status: FeedStatus.initial);
+
+ const FeedState.loading({required String actor, FeedFilter filter = FeedFilter.postsAndAuthorThreads})
+ : this._(status: FeedStatus.loading, actor: actor, filter: filter, hasMore: false);
+
+ const FeedState.loaded({
+ required String actor,
+ required List posts,
+ String? cursor,
+ FeedFilter filter = FeedFilter.postsAndAuthorThreads,
+ bool hasMore = true,
+ }) : this._(status: FeedStatus.loaded, actor: actor, posts: posts, cursor: cursor, filter: filter, hasMore: hasMore);
+
+ const FeedState.error(String message) : this._(status: FeedStatus.error, errorMessage: message);
+
+ final FeedStatus status;
+ final String? actor;
+ final List posts;
+ final String? cursor;
+ final String? errorMessage;
+ final FeedFilter filter;
+ final bool hasMore;
+ final bool isLoadingMore;
+ final bool isRefreshing;
+
+ bool get isLoading => status == FeedStatus.loading;
+ bool get hasError => status == FeedStatus.error;
+ bool get hasPosts => posts.isNotEmpty;
+
+ FeedState copyWith({
+ FeedStatus? status,
+ String? actor,
+ List? posts,
+ String? cursor,
+ String? errorMessage,
+ FeedFilter? filter,
+ bool? hasMore,
+ bool? isLoadingMore,
+ bool? isRefreshing,
+ }) {
+ return FeedState._(
+ status: status ?? this.status,
+ actor: actor ?? this.actor,
+ posts: posts ?? this.posts,
+ cursor: cursor ?? this.cursor,
+ errorMessage: errorMessage ?? this.errorMessage,
+ filter: filter ?? this.filter,
+ hasMore: hasMore ?? this.hasMore,
+ isLoadingMore: isLoadingMore ?? this.isLoadingMore,
+ isRefreshing: isRefreshing ?? this.isRefreshing,
+ );
+ }
+
+ @override
+ List get props => [status, actor, posts, cursor, errorMessage, filter, hasMore, isLoadingMore, isRefreshing];
+}
diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart
new file mode 100644
index 0000000..85824af
--- /dev/null
+++ b/lib/features/feed/data/feed_repository.dart
@@ -0,0 +1,40 @@
+import 'package:bluesky/app_bsky_feed_defs.dart';
+import 'package:bluesky/app_bsky_feed_getauthorfeed.dart';
+
+class FeedRepository {
+ FeedRepository({required dynamic bluesky}) : _bluesky = bluesky;
+
+ final dynamic _bluesky;
+
+ Future getAuthorFeed({
+ required String actor,
+ FeedFilter filter = FeedFilter.postsAndAuthorThreads,
+ String? cursor,
+ int limit = 50,
+ }) async {
+ final bskyFilter = _mapToBskyFilter(filter);
+
+ final response = await _bluesky.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter);
+
+ return FeedResult(posts: response.data.feed, cursor: response.data.cursor);
+ }
+
+ FeedGetAuthorFeedFilter? _mapToBskyFilter(FeedFilter filter) {
+ switch (filter) {
+ case FeedFilter.postsNoReplies:
+ return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_no_replies);
+ case FeedFilter.postsWithMedia:
+ return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_with_media);
+ case FeedFilter.postsAndAuthorThreads:
+ return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_and_author_threads);
+ }
+ }
+}
+
+class FeedResult {
+ FeedResult({required this.posts, this.cursor});
+ final List posts;
+ final String? cursor;
+}
+
+enum FeedFilter { postsNoReplies, postsWithMedia, postsAndAuthorThreads }
diff --git a/lib/features/feed/presentation/widgets/facet_text.dart b/lib/features/feed/presentation/widgets/facet_text.dart
new file mode 100644
index 0000000..46c7f74
--- /dev/null
+++ b/lib/features/feed/presentation/widgets/facet_text.dart
@@ -0,0 +1,210 @@
+import 'dart:convert';
+
+import 'package:bluesky/app_bsky_richtext_facet.dart';
+import 'package:bluesky_text/bluesky_text.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+class FacetText extends StatelessWidget {
+ const FacetText({super.key, required this.text, this.facets, this.style, this.maxLines, this.overflow});
+
+ final String text;
+ final List? facets;
+ final TextStyle? style;
+ final int? maxLines;
+ final TextOverflow? overflow;
+
+ @override
+ Widget build(BuildContext context) {
+ return RichText(
+ text: TextSpan(style: style, children: _buildTextSpans(context)),
+ maxLines: maxLines,
+ overflow: overflow ?? TextOverflow.clip,
+ );
+ }
+
+ List _buildTextSpans(BuildContext context) {
+ final bytes = utf8.encode(text);
+ final segments = _segmentsFromFacets(bytes) ?? _segmentsFromEntities(bytes);
+
+ if (segments.isEmpty) {
+ return [TextSpan(text: text)];
+ }
+
+ final spans = [];
+ var currentByteIndex = 0;
+
+ for (final segment in segments) {
+ final startByte = segment.start.clamp(0, bytes.length);
+ final endByte = segment.end.clamp(startByte, bytes.length);
+
+ if (startByte > currentByteIndex) {
+ spans.add(TextSpan(text: _extractTextFromBytes(bytes, currentByteIndex, startByte)));
+ }
+
+ final segmentText = _extractTextFromBytes(bytes, startByte, endByte);
+ spans.add(segment.toSpan(context, segmentText));
+ currentByteIndex = endByte;
+ }
+
+ if (currentByteIndex < bytes.length) {
+ spans.add(TextSpan(text: _extractTextFromBytes(bytes, currentByteIndex, bytes.length)));
+ }
+
+ return spans;
+ }
+
+ List<_TextSegment>? _segmentsFromFacets(List bytes) {
+ if (facets == null || facets!.isEmpty) {
+ return null;
+ }
+
+ final sortedFacets = List.from(facets!)
+ ..sort((a, b) => a.index.byteStart.compareTo(b.index.byteStart));
+ final segments = <_TextSegment>[];
+
+ for (final facet in sortedFacets) {
+ final segment = _segmentFromFacet(facet);
+ if (segment == null) {
+ continue;
+ }
+
+ if (facet.index.byteStart >= bytes.length) {
+ continue;
+ }
+
+ segments.add(segment);
+ }
+
+ return segments;
+ }
+
+ List<_TextSegment> _segmentsFromEntities(List bytes) {
+ final entities = BlueskyText(text, enableMarkdown: false).entities.toList()
+ ..sort((a, b) => a.indices.start.compareTo(b.indices.start));
+ final segments = <_TextSegment>[];
+
+ for (final entity in entities) {
+ if (entity.indices.start >= bytes.length) {
+ continue;
+ }
+
+ if (entity.type == EntityType.handle) {
+ segments.add(_MentionSegment(entity.indices.start, entity.indices.end, entity.value.replaceFirst('@', '')));
+ continue;
+ }
+
+ if (entity.type == EntityType.link) {
+ segments.add(_LinkSegment(entity.indices.start, entity.indices.end, entity.value));
+ continue;
+ }
+
+ if (entity.type == EntityType.tag) {
+ segments.add(_TagSegment(entity.indices.start, entity.indices.end, entity.value.replaceFirst('#', '')));
+ }
+ }
+
+ return segments;
+ }
+
+ _TextSegment? _segmentFromFacet(RichtextFacet facet) {
+ for (final feature in facet.features) {
+ if (feature.isRichtextFacetLink && feature.richtextFacetLink != null) {
+ return _LinkSegment(facet.index.byteStart, facet.index.byteEnd, feature.richtextFacetLink!.uri);
+ }
+
+ if (feature.isRichtextFacetMention && feature.richtextFacetMention != null) {
+ return _MentionSegment(facet.index.byteStart, facet.index.byteEnd, feature.richtextFacetMention!.did);
+ }
+
+ if (feature.isRichtextFacetTag && feature.richtextFacetTag != null) {
+ return _TagSegment(facet.index.byteStart, facet.index.byteEnd, feature.richtextFacetTag!.tag);
+ }
+ }
+
+ return null;
+ }
+
+ String _extractTextFromBytes(List bytes, int start, int end) {
+ return utf8.decode(bytes.sublist(start, end), allowMalformed: true);
+ }
+}
+
+abstract class _TextSegment {
+ const _TextSegment(this.start, this.end);
+
+ final int start;
+ final int end;
+
+ TextSpan toSpan(BuildContext context, String text);
+
+ TextStyle _linkStyle(BuildContext context) {
+ return TextStyle(
+ color: Theme.of(context).colorScheme.primary,
+ decoration: TextDecoration.underline,
+ fontWeight: FontWeight.w600,
+ );
+ }
+}
+
+final class _LinkSegment extends _TextSegment {
+ const _LinkSegment(super.start, super.end, this.uri);
+
+ final String uri;
+
+ @override
+ TextSpan toSpan(BuildContext context, String text) {
+ return TextSpan(
+ text: text,
+ style: _linkStyle(context),
+ recognizer: TapGestureRecognizer()..onTap = () => _launchExternal(Uri.parse(uri)),
+ );
+ }
+}
+
+final class _MentionSegment extends _TextSegment {
+ const _MentionSegment(super.start, super.end, this.actor);
+
+ final String actor;
+
+ @override
+ TextSpan toSpan(BuildContext context, String text) {
+ return TextSpan(
+ text: text,
+ style: _linkStyle(context),
+ recognizer: TapGestureRecognizer()..onTap = () => _openProfile(context, actor),
+ );
+ }
+}
+
+final class _TagSegment extends _TextSegment {
+ const _TagSegment(super.start, super.end, this.tag);
+
+ final String tag;
+
+ @override
+ TextSpan toSpan(BuildContext context, String text) {
+ return TextSpan(
+ text: text,
+ style: _linkStyle(context),
+ recognizer: TapGestureRecognizer()..onTap = () => _launchExternal(_searchUri(tag)),
+ );
+ }
+}
+
+void _openProfile(BuildContext context, String actor) {
+ final router = GoRouter.maybeOf(context);
+ if (router == null) {
+ return;
+ }
+
+ router.push('/profile?actor=${Uri.encodeQueryComponent(actor)}');
+}
+
+Uri _searchUri(String tag) => Uri.https('bsky.app', '/search', {'q': '#$tag'});
+
+Future _launchExternal(Uri url) async {
+ await launchUrl(url, mode: LaunchMode.externalApplication);
+}
diff --git a/lib/features/feed/presentation/widgets/post_card.dart b/lib/features/feed/presentation/widgets/post_card.dart
new file mode 100644
index 0000000..0dbcaff
--- /dev/null
+++ b/lib/features/feed/presentation/widgets/post_card.dart
@@ -0,0 +1,503 @@
+import 'package:bluesky/app_bsky_actor_defs.dart';
+import 'package:bluesky/app_bsky_embed_external.dart';
+import 'package:bluesky/app_bsky_embed_images.dart';
+import 'package:bluesky/app_bsky_embed_record.dart';
+import 'package:bluesky/app_bsky_embed_recordwithmedia.dart';
+import 'package:bluesky/app_bsky_embed_video.dart';
+import 'package:bluesky/app_bsky_feed_defs.dart';
+import 'package:bluesky/app_bsky_feed_post.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:intl/intl.dart';
+import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+class PostCard extends StatelessWidget {
+ const PostCard({super.key, required this.feedViewPost});
+
+ final FeedViewPost feedViewPost;
+
+ @override
+ Widget build(BuildContext context) {
+ final post = feedViewPost.post;
+ final record = _tryParseRecord(post.record);
+ final embed = _buildEmbed(context, post.embed);
+
+ return Card(
+ margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1),
+ elevation: 0,
+ shape: const RoundedRectangleBorder(),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt),
+ if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)],
+ if (record != null && record.text.isNotEmpty) ...[
+ const SizedBox(height: 12),
+ FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge),
+ ],
+ if (embed != null) ...[const SizedBox(height: 12), embed],
+ const SizedBox(height: 12),
+ _buildActions(context),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ CircleAvatar(
+ radius: 22,
+ backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
+ backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null,
+ child: author.avatar == null
+ ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge)
+ : null,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ author.displayName ?? author.handle,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 2),
+ Text(
+ '@${author.handle} · ${_formatTime(createdAt)}',
+ style: Theme.of(
+ context,
+ ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildReplyLabel(BuildContext context) {
+ return Row(
+ children: [
+ Icon(Icons.reply, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
+ const SizedBox(width: 6),
+ Text(
+ 'Reply in a thread',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildActions(BuildContext context) {
+ final post = feedViewPost.post;
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'),
+ _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'),
+ _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'),
+ _buildActionButton(context, Icons.share_outlined, ''),
+ ],
+ );
+ }
+
+ Widget _buildActionButton(BuildContext context, IconData icon, String count) {
+ final iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
+
+ return InkWell(
+ onTap: () {},
+ borderRadius: BorderRadius.circular(999),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ child: Row(
+ children: [
+ Icon(icon, size: 18, color: iconColor),
+ if (count.isNotEmpty) ...[
+ const SizedBox(width: 4),
+ Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget? _buildEmbed(BuildContext context, UPostViewEmbed? embed) {
+ if (embed == null) {
+ return null;
+ }
+
+ if (embed.isEmbedImagesView) {
+ return _buildImagesEmbed(context, embed.embedImagesView!.images);
+ }
+
+ if (embed.isEmbedExternalView) {
+ return _buildExternalEmbed(context, embed.embedExternalView!.external);
+ }
+
+ if (embed.isEmbedRecordView) {
+ return _buildQuotedRecord(context, embed.embedRecordView!);
+ }
+
+ if (embed.isEmbedVideoView) {
+ return _buildVideoEmbed(context, embed.embedVideoView!);
+ }
+
+ if (embed.isEmbedRecordWithMediaView) {
+ final recordWithMedia = embed.embedRecordWithMediaView!;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildRecordWithMediaMedia(context, recordWithMedia.media),
+ const SizedBox(height: 8),
+ _buildQuotedRecord(context, recordWithMedia.record),
+ ],
+ );
+ }
+
+ return null;
+ }
+
+ Widget _buildRecordWithMediaMedia(BuildContext context, UEmbedRecordWithMediaViewMedia media) {
+ if (media.isEmbedImagesView) {
+ return _buildImagesEmbed(context, media.embedImagesView!.images);
+ }
+
+ if (media.isEmbedExternalView) {
+ return _buildExternalEmbed(context, media.embedExternalView!.external);
+ }
+
+ if (media.isEmbedVideoView) {
+ return _buildVideoEmbed(context, media.embedVideoView!);
+ }
+
+ return const SizedBox.shrink();
+ }
+
+ Widget _buildImagesEmbed(BuildContext context, List images) {
+ final crossAxisCount = images.length == 1 ? 1 : 2;
+ final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0;
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ itemCount: images.length,
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: crossAxisCount,
+ crossAxisSpacing: 2,
+ mainAxisSpacing: 2,
+ childAspectRatio: childAspectRatio,
+ ),
+ itemBuilder: (context, index) {
+ final image = images[index];
+ return InkWell(
+ onTap: () => _launchExternal(Uri.parse(image.fullsize)),
+ child: Image.network(
+ image.thumb,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => ColoredBox(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: const Center(child: Icon(Icons.image_not_supported_outlined)),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) {
+ return InkWell(
+ onTap: () => _launchExternal(Uri.parse(external.uri)),
+ borderRadius: BorderRadius.circular(16),
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border.all(color: Theme.of(context).dividerColor),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (external.thumb != null)
+ ClipRRect(
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+ child: Image.network(
+ external.thumb!,
+ height: 160,
+ width: double.infinity,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => const SizedBox(height: 0),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ external.title,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ if (external.description.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(
+ external.description,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ],
+ const SizedBox(height: 8),
+ Text(
+ Uri.parse(external.uri).host,
+ style: Theme.of(
+ context,
+ ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) {
+ return InkWell(
+ onTap: () => _launchExternal(Uri.parse(video.playlist)),
+ borderRadius: BorderRadius.circular(16),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ AspectRatio(
+ aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height,
+ child: video.thumbnail == null
+ ? ColoredBox(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: const SizedBox.expand(),
+ )
+ : Image.network(
+ video.thumbnail!,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => ColoredBox(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: const SizedBox.expand(),
+ ),
+ ),
+ ),
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle),
+ child: const Icon(Icons.play_arrow, color: Colors.white, size: 28),
+ ),
+ if (video.alt?.isNotEmpty ?? false)
+ Positioned(
+ left: 12,
+ right: 12,
+ bottom: 12,
+ child: Text(
+ video.alt!,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) {
+ final record = recordView.record;
+
+ if (record.isEmbedRecordViewRecord) {
+ final quoted = record.embedRecordViewRecord!;
+ final quotedRecord = _tryParseRecord(quoted.value);
+ final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds);
+
+ return Container(
+ decoration: BoxDecoration(
+ border: Border.all(color: Theme.of(context).dividerColor),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: InkWell(
+ onTap: () {
+ final router = GoRouter.maybeOf(context);
+ if (router != null) {
+ router.push('/profile?actor=${Uri.encodeQueryComponent(quoted.author.did)}');
+ }
+ },
+ borderRadius: BorderRadius.circular(16),
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 14,
+ backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
+ backgroundImage: quoted.author.avatar != null ? NetworkImage(quoted.author.avatar!) : null,
+ child: quoted.author.avatar == null
+ ? Text(_initials(quoted.author.displayName ?? quoted.author.handle))
+ : null,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ ),
+ ],
+ ),
+ if (quotedRecord != null && quotedRecord.text.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ FacetText(
+ text: quotedRecord.text,
+ facets: quotedRecord.facets,
+ style: Theme.of(context).textTheme.bodyMedium,
+ maxLines: 6,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ if (nestedEmbed != null) ...[const SizedBox(height: 8), nestedEmbed],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ if (record.isEmbedRecordViewNotFound) {
+ return _buildUnavailableQuote(context, 'Quoted post not found');
+ }
+
+ if (record.isEmbedRecordViewBlocked) {
+ return _buildUnavailableQuote(context, 'Quoted post is blocked');
+ }
+
+ if (record.isEmbedRecordViewDetached) {
+ return _buildUnavailableQuote(context, 'Quoted post is unavailable');
+ }
+
+ return const SizedBox.shrink();
+ }
+
+ Widget _buildUnavailableQuote(BuildContext context, String label) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ );
+ }
+
+ Widget? _buildQuotedEmbeds(BuildContext context, List? embeds) {
+ if (embeds == null || embeds.isEmpty) {
+ return null;
+ }
+
+ final embed = embeds.first;
+
+ if (embed.isEmbedImagesView) {
+ return _buildImagesEmbed(context, embed.embedImagesView!.images);
+ }
+
+ if (embed.isEmbedExternalView) {
+ return _buildExternalEmbed(context, embed.embedExternalView!.external);
+ }
+
+ if (embed.isEmbedVideoView) {
+ return _buildVideoEmbed(context, embed.embedVideoView!);
+ }
+
+ if (embed.isEmbedRecordWithMediaView) {
+ final recordWithMedia = embed.embedRecordWithMediaView!;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildRecordWithMediaMedia(context, recordWithMedia.media),
+ const SizedBox(height: 8),
+ _buildQuotedRecord(context, recordWithMedia.record),
+ ],
+ );
+ }
+
+ return null;
+ }
+
+ FeedPostRecord? _tryParseRecord(Map record) {
+ try {
+ return FeedPostRecord.fromJson(record);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ String _formatTime(DateTime time) {
+ final now = DateTime.now();
+ final difference = now.difference(time);
+
+ if (difference.inMinutes < 1) {
+ return 'now';
+ }
+
+ if (difference.inHours < 1) {
+ return '${difference.inMinutes}m';
+ }
+
+ if (difference.inDays < 1) {
+ return '${difference.inHours}h';
+ }
+
+ if (difference.inDays < 7) {
+ return '${difference.inDays}d';
+ }
+
+ return DateFormat('MMM d').format(time);
+ }
+
+ String _initials(String value) {
+ final parts = value.trim().split(RegExp(r'\s+'));
+ if (parts.isEmpty || parts.first.isEmpty) {
+ return '?';
+ }
+
+ if (parts.length == 1) {
+ return parts.first.substring(0, 1).toUpperCase();
+ }
+
+ return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase();
+ }
+}
+
+Future _launchExternal(Uri url) async {
+ await launchUrl(url, mode: LaunchMode.externalApplication);
+}
diff --git a/lib/features/profile/bloc/profile_bloc.dart b/lib/features/profile/bloc/profile_bloc.dart
new file mode 100644
index 0000000..758c3d5
--- /dev/null
+++ b/lib/features/profile/bloc/profile_bloc.dart
@@ -0,0 +1,45 @@
+import 'package:bluesky/app_bsky_actor_defs.dart';
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:lazurite/features/profile/data/profile_repository.dart';
+
+part 'profile_event.dart';
+part 'profile_state.dart';
+
+class ProfileBloc extends Bloc {
+ ProfileBloc({required ProfileRepository profileRepository})
+ : _profileRepository = profileRepository,
+ super(const ProfileState.initial()) {
+ on(_onProfileLoadRequested);
+ on(_onProfileRefreshRequested);
+ }
+
+ final ProfileRepository _profileRepository;
+
+ Future _onProfileLoadRequested(ProfileLoadRequested event, Emitter emit) async {
+ emit(const ProfileState.loading());
+
+ try {
+ final profile = await _profileRepository.getProfile(event.actor);
+ emit(ProfileState.loaded(profile: profile));
+ } catch (error) {
+ emit(ProfileState.error('Failed to load profile: $error'));
+ }
+ }
+
+ Future _onProfileRefreshRequested(ProfileRefreshRequested event, Emitter emit) async {
+ if (state.status != ProfileStatus.loaded || state.profile == null) {
+ return;
+ }
+
+ final currentProfile = state.profile!;
+ emit(state.copyWith(isRefreshing: true));
+
+ try {
+ final profile = await _profileRepository.getProfile(currentProfile.did);
+ emit(ProfileState.loaded(profile: profile));
+ } catch (error) {
+ emit(state.copyWith(isRefreshing: false));
+ }
+ }
+}
diff --git a/lib/features/profile/bloc/profile_event.dart b/lib/features/profile/bloc/profile_event.dart
new file mode 100644
index 0000000..9cc01f1
--- /dev/null
+++ b/lib/features/profile/bloc/profile_event.dart
@@ -0,0 +1,20 @@
+part of 'profile_bloc.dart';
+
+abstract class ProfileEvent extends Equatable {
+ const ProfileEvent();
+
+ @override
+ List get props => [];
+}
+
+class ProfileLoadRequested extends ProfileEvent {
+ const ProfileLoadRequested({required this.actor});
+ final String actor;
+
+ @override
+ List get props => [actor];
+}
+
+class ProfileRefreshRequested extends ProfileEvent {
+ const ProfileRefreshRequested();
+}
diff --git a/lib/features/profile/bloc/profile_state.dart b/lib/features/profile/bloc/profile_state.dart
new file mode 100644
index 0000000..36e3b7b
--- /dev/null
+++ b/lib/features/profile/bloc/profile_state.dart
@@ -0,0 +1,42 @@
+part of 'profile_bloc.dart';
+
+enum ProfileStatus { initial, loading, loaded, error }
+
+class ProfileState extends Equatable {
+ const ProfileState._({required this.status, this.profile, this.errorMessage, this.isRefreshing = false});
+
+ const ProfileState.initial() : this._(status: ProfileStatus.initial);
+
+ const ProfileState.loading() : this._(status: ProfileStatus.loading);
+
+ const ProfileState.loaded({required ProfileViewDetailed profile})
+ : this._(status: ProfileStatus.loaded, profile: profile);
+
+ const ProfileState.error(String message) : this._(status: ProfileStatus.error, errorMessage: message);
+
+ final ProfileStatus status;
+ final ProfileViewDetailed? profile;
+ final String? errorMessage;
+ final bool isRefreshing;
+
+ bool get isLoading => status == ProfileStatus.loading;
+ bool get hasError => status == ProfileStatus.error;
+ bool get hasProfile => profile != null;
+
+ ProfileState copyWith({
+ ProfileStatus? status,
+ ProfileViewDetailed? profile,
+ String? errorMessage,
+ bool? isRefreshing,
+ }) {
+ return ProfileState._(
+ status: status ?? this.status,
+ profile: profile ?? this.profile,
+ errorMessage: errorMessage ?? this.errorMessage,
+ isRefreshing: isRefreshing ?? this.isRefreshing,
+ );
+ }
+
+ @override
+ List get props => [status, profile, errorMessage, isRefreshing];
+}
diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart
new file mode 100644
index 0000000..516fa92
--- /dev/null
+++ b/lib/features/profile/data/profile_repository.dart
@@ -0,0 +1,63 @@
+import 'dart:convert';
+
+import 'package:bluesky/app_bsky_actor_defs.dart';
+import 'package:lazurite/core/database/app_database.dart';
+import 'package:lazurite/features/auth/data/models/auth_models.dart';
+
+class ProfileRepository {
+ ProfileRepository({required AppDatabase database, required dynamic bluesky})
+ : _database = database,
+ _bluesky = bluesky;
+
+ final AppDatabase _database;
+ final dynamic _bluesky;
+
+ Future getProfile(String actor) async {
+ try {
+ final response = await _bluesky.actor.getProfile(actor: actor);
+ final profile = response.data;
+
+ await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson()));
+
+ return profile;
+ } catch (error) {
+ final cachedProfile = await _getCachedProfile(actor);
+ if (cachedProfile != null) {
+ return cachedProfile;
+ }
+
+ rethrow;
+ }
+ }
+
+ Future> getProfiles(List actors) async {
+ final response = await _bluesky.actor.getProfiles(actors: actors);
+ return response.data.profiles;
+ }
+
+ Future getCurrentUserProfile(AuthTokens tokens) async {
+ try {
+ final response = await _bluesky.actor.getProfile(actor: tokens.did);
+ return response.data;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ Future _getCachedProfile(String actor) async {
+ final cachedProfileByDid = await (_database.select(
+ _database.cachedProfiles,
+ )..where((profile) => profile.did.equals(actor))).getSingleOrNull();
+ final cachedProfile =
+ cachedProfileByDid ??
+ await (_database.select(
+ _database.cachedProfiles,
+ )..where((profile) => profile.handle.equals(actor))).getSingleOrNull();
+
+ if (cachedProfile == null) {
+ return null;
+ }
+
+ return ProfileViewDetailed.fromJson(jsonDecode(cachedProfile.payload) as Map);
+ }
+}
diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart
index 16c22b9..e747d31 100644
--- a/lib/features/profile/presentation/profile_screen.dart
+++ b/lib/features/profile/presentation/profile_screen.dart
@@ -1,37 +1,429 @@
+import 'package:bluesky/app_bsky_actor_defs.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+import 'package:intl/intl.dart';
import 'package:lazurite/features/auth/bloc/auth_bloc.dart';
+import 'package:lazurite/features/feed/bloc/feed_bloc.dart';
+import 'package:lazurite/features/feed/presentation/widgets/post_card.dart';
+import 'package:lazurite/features/profile/bloc/profile_bloc.dart';
+import 'package:url_launcher/url_launcher.dart';
-class ProfileScreen extends StatelessWidget {
- const ProfileScreen({super.key});
+class ProfileScreen extends StatefulWidget {
+ const ProfileScreen({super.key, this.actor});
+
+ final String? actor;
@override
- Widget build(BuildContext context) {
- final state = context.watch().state;
- final tokens = state.tokens;
+ State createState() => _ProfileScreenState();
+}
+
+class _ProfileScreenState extends State with SingleTickerProviderStateMixin {
+ static const _tabs = [
+ (label: 'Posts', filter: FeedFilter.postsNoReplies),
+ (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads),
+ (label: 'Media', filter: FeedFilter.postsWithMedia),
+ ];
+
+ late final TabController _tabController;
+
+ @override
+ void initState() {
+ super.initState();
+ _tabController = TabController(length: _tabs.length, vsync: this);
+ _loadProfileAndFeed();
+ }
+
+ @override
+ void didUpdateWidget(covariant ProfileScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.actor != widget.actor) {
+ _tabController.index = 0;
+ _loadProfileAndFeed();
+ }
+ }
+
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ void _loadProfileAndFeed({FeedFilter? filter}) {
+ final actor = _resolvedActor;
+ if (actor == null) {
+ return;
+ }
+
+ context.read().add(ProfileLoadRequested(actor: actor));
+ context.read().add(FeedLoadRequested(actor: actor, filter: filter ?? _currentFilter));
+ }
+
+ String? get _resolvedActor {
+ final authState = context.read().state;
+ if (!authState.isAuthenticated) {
+ return null;
+ }
+
+ return widget.actor ?? authState.tokens?.did;
+ }
+
+ FeedFilter get _currentFilter => _tabs[_tabController.index].filter;
+ Future _refresh() async {
+ context.read().add(const ProfileRefreshRequested());
+ context.read().add(const FeedRefreshRequested());
+ await Future.delayed(const Duration(milliseconds: 250));
+ }
+
+ @override
+ Widget build(BuildContext context) {
return Scaffold(
- appBar: AppBar(title: const Text('Profile')),
- body: tokens == null
- ? const Center(child: Text('No active account'))
- : Padding(
- padding: const EdgeInsets.all(24),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(tokens.displayName ?? tokens.handle, style: Theme.of(context).textTheme.headlineMedium),
- const SizedBox(height: 8),
- Text('@${tokens.handle}', style: Theme.of(context).textTheme.titleMedium),
- const SizedBox(height: 12),
- SelectableText(tokens.did, style: Theme.of(context).textTheme.bodyMedium),
- const SizedBox(height: 24),
- Text(
- 'Phase 1 milestone 0 requires the profile route and feature structure. Full profile rendering is scheduled in milestone 2.',
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- ],
- ),
+ body: BlocBuilder(
+ builder: (context, profileState) {
+ return BlocBuilder(
+ builder: (context, feedState) {
+ final profile = profileState.profile;
+
+ return NestedScrollView(
+ headerSliverBuilder: (context, innerBoxIsScrolled) {
+ return [
+ SliverAppBar(
+ expandedHeight: 220,
+ pinned: true,
+ stretch: true,
+ flexibleSpace: FlexibleSpaceBar(
+ title: Text(profile?.displayName ?? profile?.handle ?? 'Profile'),
+ background: _buildBanner(profile),
+ ),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () => context.canPop() ? context.pop() : context.go('/'),
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.settings_outlined),
+ onPressed: () => context.push('/settings'),
+ ),
+ ],
+ ),
+ SliverToBoxAdapter(
+ child: switch (profileState.status) {
+ ProfileStatus.loading => const Padding(
+ padding: EdgeInsets.all(24),
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ ProfileStatus.error => _buildProfileError(context, profileState.errorMessage),
+ _ => _buildProfileSummary(context, profile),
+ },
+ ),
+ SliverPersistentHeader(
+ pinned: true,
+ delegate: _SliverTabBarDelegate(
+ TabBar(
+ controller: _tabController,
+ tabs: [for (final tab in _tabs) Tab(text: tab.label)],
+ onTap: (index) => _loadProfileAndFeed(filter: _tabs[index].filter),
+ ),
+ ),
+ ),
+ ];
+ },
+ body: TabBarView(
+ controller: _tabController,
+ children: [for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter)],
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildBanner(ProfileViewDetailed? profile) {
+ if (profile?.banner == null) {
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ );
+ }
+
+ return Image.network(
+ profile!.banner!,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildProfileError(BuildContext context, String? errorMessage) {
+ return Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Unable to load profile', style: Theme.of(context).textTheme.titleLarge),
+ const SizedBox(height: 8),
+ Text(errorMessage ?? 'Unknown error', style: Theme.of(context).textTheme.bodyMedium),
+ const SizedBox(height: 12),
+ FilledButton(onPressed: _loadProfileAndFeed, child: const Text('Try again')),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildProfileSummary(BuildContext context, ProfileViewDetailed? profile) {
+ if (profile == null) {
+ return const SizedBox.shrink();
+ }
+
+ final metaChildren = [
+ if (profile.pronouns?.isNotEmpty ?? false)
+ _buildMetaChip(context, Icons.record_voice_over_outlined, profile.pronouns!),
+ if (profile.website?.isNotEmpty ?? false)
+ _buildMetaChip(context, Icons.link_outlined, profile.website!, onTap: () => _launchWebsite(profile.website!)),
+ if (profile.createdAt != null)
+ _buildMetaChip(
+ context,
+ Icons.calendar_today_outlined,
+ 'Joined ${DateFormat.yMMMM().format(profile.createdAt!)}',
+ ),
+ ];
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Transform.translate(offset: const Offset(0, -36), child: _buildAvatar(profile)),
+ Text(
+ profile.displayName ?? profile.handle,
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '@${profile.handle}',
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ if (profile.description?.isNotEmpty ?? false) ...[
+ const SizedBox(height: 12),
+ Text(profile.description!, style: Theme.of(context).textTheme.bodyLarge),
+ ],
+ if (metaChildren.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ Wrap(spacing: 8, runSpacing: 8, children: metaChildren),
+ ],
+ const SizedBox(height: 16),
+ Wrap(
+ spacing: 16,
+ runSpacing: 8,
+ children: [
+ _buildStat(context, profile.followsCount ?? 0, 'Following'),
+ _buildStat(context, profile.followersCount ?? 0, 'Followers'),
+ _buildStat(context, profile.postsCount ?? 0, 'Posts'),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildAvatar(ProfileViewDetailed profile) {
+ final avatarUrl = profile.avatar;
+
+ return Container(
+ width: 96,
+ height: 96,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 4),
+ ),
+ child: CircleAvatar(
+ radius: 44,
+ backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
+ backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
+ child: avatarUrl == null
+ ? Text(_initials(profile.displayName ?? profile.handle), style: Theme.of(context).textTheme.headlineSmall)
+ : null,
+ ),
+ );
+ }
+
+ Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) {
+ final chip = Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
+ const SizedBox(width: 6),
+ Flexible(
+ child: Text(
+ label,
+ style: Theme.of(
+ context,
+ ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
+ ),
+ ],
+ ),
);
+
+ if (onTap == null) {
+ return chip;
+ }
+
+ return InkWell(onTap: onTap, borderRadius: BorderRadius.circular(999), child: chip);
+ }
+
+ Widget _buildStat(BuildContext context, int count, String label) {
+ return RichText(
+ text: TextSpan(
+ style: Theme.of(context).textTheme.bodyMedium,
+ children: [
+ TextSpan(
+ text: _formatCount(count),
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ TextSpan(
+ text: ' $label',
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter) {
+ if (feedState.isLoading && feedState.filter == tabFilter) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (feedState.hasError && feedState.filter == tabFilter) {
+ return Center(child: Text(feedState.errorMessage ?? 'Failed to load posts'));
+ }
+
+ if (feedState.filter != tabFilter) {
+ return const SizedBox.shrink();
+ }
+
+ if (!feedState.hasPosts) {
+ return Center(child: Text(_emptyLabel(tabFilter)));
+ }
+
+ return RefreshIndicator(
+ onRefresh: _refresh,
+ child: NotificationListener(
+ onNotification: (notification) {
+ if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 &&
+ feedState.hasMore &&
+ !feedState.isLoadingMore) {
+ context.read().add(const FeedLoadMoreRequested());
+ }
+
+ return false;
+ },
+ child: ListView.builder(
+ padding: EdgeInsets.zero,
+ itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0),
+ itemBuilder: (context, index) {
+ if (index >= feedState.posts.length) {
+ return const Padding(
+ padding: EdgeInsets.all(16),
+ child: Center(child: CircularProgressIndicator()),
+ );
+ }
+
+ return PostCard(feedViewPost: feedState.posts[index]);
+ },
+ ),
+ ),
+ );
+ }
+
+ String _emptyLabel(FeedFilter filter) {
+ switch (filter) {
+ case FeedFilter.postsNoReplies:
+ return 'No posts yet';
+ case FeedFilter.postsAndAuthorThreads:
+ return 'No replies or threads yet';
+ case FeedFilter.postsWithMedia:
+ return 'No media posts yet';
+ }
+ }
+
+ String _formatCount(int count) {
+ if (count >= 1000000) {
+ return '${(count / 1000000).toStringAsFixed(1)}M';
+ }
+
+ if (count >= 1000) {
+ return '${(count / 1000).toStringAsFixed(1)}K';
+ }
+
+ return '$count';
+ }
+
+ String _initials(String value) {
+ final parts = value.trim().split(RegExp(r'\s+'));
+ if (parts.isEmpty || parts.first.isEmpty) {
+ return '?';
+ }
+
+ if (parts.length == 1) {
+ return parts.first.substring(0, 1).toUpperCase();
+ }
+
+ return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase();
}
+
+ Future _launchWebsite(String website) async {
+ final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website');
+ if (uri == null) {
+ return;
+ }
+
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ }
+}
+
+class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate {
+ _SliverTabBarDelegate(this.tabBar);
+
+ final TabBar tabBar;
+
+ @override
+ double get minExtent => tabBar.preferredSize.height;
+
+ @override
+ double get maxExtent => tabBar.preferredSize.height;
+
+ @override
+ Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
+ return ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: tabBar);
+ }
+
+ @override
+ bool shouldRebuild(_SliverTabBarDelegate oldDelegate) => false;
}
diff --git a/lib/main.dart b/lib/main.dart
index 6bbeca2..50e7a9c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,9 +1,15 @@
+import 'package:atproto_core/atproto_core.dart' as atp_core;
+import 'package:bluesky/bluesky.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lazurite/core/database/app_database.dart';
import 'package:lazurite/core/router/app_router.dart';
import 'package:lazurite/features/auth/bloc/auth_bloc.dart';
import 'package:lazurite/features/auth/data/auth_repository.dart';
+import 'package:lazurite/features/feed/bloc/feed_bloc.dart';
+import 'package:lazurite/features/feed/data/feed_repository.dart';
+import 'package:lazurite/features/profile/bloc/profile_bloc.dart';
+import 'package:lazurite/features/profile/data/profile_repository.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -18,13 +24,50 @@ Future main() async {
: const AuthState.unauthenticated(),
);
- runApp(LazuriteApp(authBloc: authBloc));
+ runApp(LazuriteApp(authBloc: authBloc, database: database));
}
class LazuriteApp extends StatelessWidget {
- const LazuriteApp({super.key, required this.authBloc});
+ const LazuriteApp({super.key, required this.authBloc, required this.database});
final AuthBloc authBloc;
+ final AppDatabase database;
+
+ Bluesky? _createBluesky(AuthState state) {
+ if (!state.isAuthenticated || state.tokens == null) {
+ return null;
+ }
+
+ final tokens = state.tokens!;
+ final service = tokens.service ?? 'bsky.social';
+
+ if (tokens.usesOAuth) {
+ if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) {
+ return null;
+ }
+
+ final oauthSession = atp_core.restoreOAuthSession(
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken!,
+ dPoPNonce: tokens.dpopNonce,
+ publicKey: tokens.dpopPublicKey!,
+ privateKey: tokens.dpopPrivateKey!,
+ );
+ return Bluesky.fromOAuthSession(oauthSession, service: service);
+ }
+
+ if (tokens.refreshToken == null) {
+ return null;
+ }
+
+ final session = atp_core.Session(
+ did: tokens.did,
+ handle: tokens.handle,
+ accessJwt: tokens.accessToken,
+ refreshJwt: tokens.refreshToken!,
+ );
+ return Bluesky.fromSession(session, service: service);
+ }
@override
Widget build(BuildContext context) {
@@ -32,11 +75,31 @@ class LazuriteApp extends StatelessWidget {
return BlocProvider.value(
value: authBloc,
- child: MaterialApp.router(
- title: 'Lazurite',
- debugShowCheckedModeBanner: false,
- theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
- routerConfig: router,
+ child: BlocBuilder(
+ builder: (context, authState) {
+ final bluesky = _createBluesky(authState);
+
+ return MultiBlocProvider(
+ providers: [
+ if (bluesky != null) ...[
+ BlocProvider(
+ create: (_) => ProfileBloc(
+ profileRepository: ProfileRepository(database: database, bluesky: bluesky),
+ ),
+ ),
+ BlocProvider(
+ create: (_) => FeedBloc(feedRepository: FeedRepository(bluesky: bluesky)),
+ ),
+ ],
+ ],
+ child: MaterialApp.router(
+ title: 'Lazurite',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
+ routerConfig: router,
+ ),
+ );
+ },
),
);
}
diff --git a/pubspec.lock b/pubspec.lock
index 9e739a2..4bb79ea 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -472,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ intl:
+ dependency: "direct main"
+ description:
+ name: intl
+ sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.19.0"
io:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 459abd1..778461d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -26,6 +26,7 @@ dependencies:
json_annotation: ^4.9.0
crypto: ^3.0.6
url_launcher: ^6.3.1
+ intl: ^0.19.0
dev_dependencies:
flutter_test:
diff --git a/test/features/feed/bloc/feed_bloc_test.dart b/test/features/feed/bloc/feed_bloc_test.dart
new file mode 100644
index 0000000..3e271ad
--- /dev/null
+++ b/test/features/feed/bloc/feed_bloc_test.dart
@@ -0,0 +1,100 @@
+import 'package:atproto_core/atproto_core.dart';
+import 'package:bloc_test/bloc_test.dart';
+import 'package:bluesky/app_bsky_actor_defs.dart';
+import 'package:bluesky/app_bsky_feed_defs.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lazurite/features/feed/bloc/feed_bloc.dart';
+import 'package:lazurite/features/feed/data/feed_repository.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockFeedRepository extends Mock implements FeedRepository {}
+
+void main() {
+ late MockFeedRepository mockFeedRepository;
+
+ setUp(() {
+ mockFeedRepository = MockFeedRepository();
+ });
+
+ group('FeedBloc', () {
+ final samplePost = FeedViewPost(
+ post: PostView(
+ uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc'),
+ cid: 'cid-123',
+ author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'),
+ record: {
+ r'$type': 'app.bsky.feed.post',
+ 'text': 'Hello world',
+ 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(),
+ },
+ indexedAt: DateTime.utc(2026, 3, 15),
+ ),
+ );
+
+ blocTest(
+ 'loads a feed for the requested actor and filter',
+ build: () => FeedBloc(feedRepository: mockFeedRepository),
+ setUp: () {
+ when(
+ () => mockFeedRepository.getAuthorFeed(actor: 'did:plc:target', filter: FeedFilter.postsWithMedia, limit: 25),
+ ).thenAnswer((_) async => FeedResult(posts: [samplePost], cursor: 'cursor-1'));
+ },
+ act: (bloc) =>
+ bloc.add(const FeedLoadRequested(actor: 'did:plc:target', filter: FeedFilter.postsWithMedia, limit: 25)),
+ expect: () => [
+ const FeedState.loading(actor: 'did:plc:target', filter: FeedFilter.postsWithMedia),
+ predicate(
+ (state) =>
+ state.status == FeedStatus.loaded &&
+ state.actor == 'did:plc:target' &&
+ state.filter == FeedFilter.postsWithMedia &&
+ state.cursor == 'cursor-1' &&
+ state.posts.length == 1,
+ ),
+ ],
+ );
+
+ blocTest(
+ 'uses the tracked actor for pagination instead of deriving it from returned posts',
+ build: () => FeedBloc(feedRepository: mockFeedRepository),
+ seed: () => FeedState.loaded(
+ actor: 'did:plc:requested',
+ posts: [samplePost],
+ cursor: 'cursor-2',
+ filter: FeedFilter.postsAndAuthorThreads,
+ hasMore: true,
+ ),
+ setUp: () {
+ when(
+ () => mockFeedRepository.getAuthorFeed(
+ actor: 'did:plc:requested',
+ filter: FeedFilter.postsAndAuthorThreads,
+ cursor: 'cursor-2',
+ limit: 50,
+ ),
+ ).thenAnswer((_) async => FeedResult(posts: const [], cursor: null));
+ },
+ act: (bloc) => bloc.add(const FeedLoadMoreRequested()),
+ expect: () => [
+ predicate((state) => state.isLoadingMore && state.actor == 'did:plc:requested'),
+ predicate(
+ (state) =>
+ state.status == FeedStatus.loaded &&
+ state.actor == 'did:plc:requested' &&
+ !state.isLoadingMore &&
+ !state.hasMore,
+ ),
+ ],
+ verify: (_) {
+ verify(
+ () => mockFeedRepository.getAuthorFeed(
+ actor: 'did:plc:requested',
+ filter: FeedFilter.postsAndAuthorThreads,
+ cursor: 'cursor-2',
+ limit: 50,
+ ),
+ ).called(1);
+ },
+ );
+ });
+}
diff --git a/test/features/feed/presentation/post_card_test.dart b/test/features/feed/presentation/post_card_test.dart
new file mode 100644
index 0000000..d8fd0f3
--- /dev/null
+++ b/test/features/feed/presentation/post_card_test.dart
@@ -0,0 +1,82 @@
+import 'dart:convert';
+
+import 'package:atproto_core/atproto_core.dart';
+import 'package:bluesky/app_bsky_actor_defs.dart';
+import 'package:bluesky/app_bsky_embed_external.dart';
+import 'package:bluesky/app_bsky_feed_defs.dart';
+import 'package:bluesky/app_bsky_feed_post.dart';
+import 'package:bluesky/app_bsky_richtext_facet.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lazurite/features/feed/presentation/widgets/post_card.dart';
+
+void main() {
+ Widget buildSubject(FeedViewPost post) {
+ return MaterialApp(
+ home: Scaffold(body: PostCard(feedViewPost: post)),
+ );
+ }
+
+ testWidgets('renders UTF-8 facet ranges without corrupting text', (tester) async {
+ const text = 'Launch 🚀 #tag';
+ final start = utf8.encode('Launch 🚀 ').length;
+ final end = utf8.encode(text).length;
+
+ final record = FeedPostRecord(
+ text: text,
+ createdAt: DateTime.utc(2026, 3, 16),
+ facets: [
+ RichtextFacet(
+ index: RichtextFacetByteSlice(byteStart: start, byteEnd: end),
+ features: const [URichtextFacetFeatures.richtextFacetTag(data: RichtextFacetTag(tag: 'tag'))],
+ ),
+ ],
+ );
+
+ final post = FeedViewPost(
+ post: PostView(
+ uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'),
+ cid: 'cid-xyz',
+ author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'),
+ record: record.toJson(),
+ indexedAt: DateTime.utc(2026, 3, 16),
+ ),
+ );
+
+ await tester.pumpWidget(buildSubject(post));
+
+ final richTextFinder = find.byWidgetPredicate(
+ (widget) => widget is RichText && widget.text.toPlainText() == 'Launch 🚀 #tag',
+ );
+
+ expect(richTextFinder, findsOneWidget);
+ });
+
+ testWidgets('renders external link card embeds', (tester) async {
+ final record = FeedPostRecord(text: 'Read this', createdAt: DateTime.utc(2026, 3, 16));
+ final post = FeedViewPost(
+ post: PostView(
+ uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'),
+ cid: 'cid-xyz',
+ author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'),
+ record: record.toJson(),
+ indexedAt: DateTime.utc(2026, 3, 16),
+ embed: const UPostViewEmbed.embedExternalView(
+ data: EmbedExternalView(
+ external: EmbedExternalViewExternal(
+ uri: 'https://example.com/article',
+ title: 'Example Article',
+ description: 'A useful external card',
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(buildSubject(post));
+
+ expect(find.text('Example Article'), findsOneWidget);
+ expect(find.text('A useful external card'), findsOneWidget);
+ expect(find.text('example.com'), findsOneWidget);
+ });
+}
diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart
new file mode 100644
index 0000000..d32e3ff
--- /dev/null
+++ b/test/features/profile/presentation/profile_screen_test.dart
@@ -0,0 +1,113 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:bluesky/app_bsky_actor_defs.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lazurite/features/auth/bloc/auth_bloc.dart';
+import 'package:lazurite/features/auth/data/models/auth_models.dart';
+import 'package:lazurite/features/feed/bloc/feed_bloc.dart';
+import 'package:lazurite/features/profile/bloc/profile_bloc.dart';
+import 'package:lazurite/features/profile/presentation/profile_screen.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockAuthBloc extends MockBloc implements AuthBloc {}
+
+class MockProfileBloc extends MockBloc implements ProfileBloc {}
+
+class MockFeedBloc extends MockBloc implements FeedBloc {}
+
+void main() {
+ late MockAuthBloc authBloc;
+ late MockProfileBloc profileBloc;
+ late MockFeedBloc feedBloc;
+
+ const tokens = AuthTokens(
+ accessToken: 'access',
+ refreshToken: 'refresh',
+ did: 'did:plc:me',
+ handle: 'me.bsky.social',
+ );
+
+ final profile = ProfileViewDetailed(
+ did: 'did:plc:me',
+ handle: 'me.bsky.social',
+ displayName: 'River Tam',
+ description: 'Signal and signal boost.',
+ pronouns: 'she/her',
+ website: 'river.example',
+ followersCount: 1200,
+ followsCount: 64,
+ postsCount: 512,
+ createdAt: DateTime.utc(2024, 3, 1),
+ );
+
+ setUp(() {
+ authBloc = MockAuthBloc();
+ profileBloc = MockProfileBloc();
+ feedBloc = MockFeedBloc();
+
+ when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens));
+ when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile));
+ when(() => feedBloc.state).thenReturn(
+ const FeedState.loaded(actor: 'did:plc:me', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false),
+ );
+
+ whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens));
+ whenListen(profileBloc, const Stream.empty(), initialState: ProfileState.loaded(profile: profile));
+ whenListen(
+ feedBloc,
+ const Stream.empty(),
+ initialState: const FeedState.loaded(
+ actor: 'did:plc:me',
+ posts: [],
+ filter: FeedFilter.postsNoReplies,
+ hasMore: false,
+ ),
+ );
+ });
+
+ Widget buildSubject() {
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: authBloc),
+ BlocProvider.value(value: profileBloc),
+ BlocProvider.value(value: feedBloc),
+ ],
+ child: const MaterialApp(home: ProfileScreen()),
+ );
+ }
+
+ testWidgets('loads posts filter by default and renders the required profile fields', (tester) async {
+ await tester.pumpWidget(buildSubject());
+
+ verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1);
+ verify(
+ () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)),
+ ).called(1);
+
+ expect(find.text('River Tam'), findsAtLeastNWidgets(1));
+ expect(find.text('@me.bsky.social'), findsOneWidget);
+ expect(find.text('Signal and signal boost.'), findsOneWidget);
+ expect(find.text('she/her'), findsOneWidget);
+ expect(find.text('river.example'), findsOneWidget);
+ expect(find.text('Joined March 2024'), findsOneWidget);
+ });
+
+ testWidgets('maps tabs to the expected server filters', (tester) async {
+ await tester.pumpWidget(buildSubject());
+
+ await tester.tap(find.text('Replies'));
+ await tester.pump();
+
+ verify(
+ () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsAndAuthorThreads)),
+ ).called(1);
+
+ await tester.tap(find.text('Media'));
+ await tester.pump();
+
+ verify(
+ () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsWithMedia)),
+ ).called(1);
+ });
+}