From 855a150e32923523880072c6efe86b62d651cec7 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 16 Mar 2026 14:28:18 -0500 Subject: [PATCH] feat: profile persistence, rendering, and state --- docs/designs/logs.html | 580 ++++++++++++++++++ docs/designs/settings.html | 21 + docs/specs/phase-2.md | 80 +++ docs/tasks/phase-1.md | 10 +- docs/tasks/phase-2.md | 21 +- lib/core/router/app_router.dart | 5 +- lib/features/auth/data/auth_repository.dart | 4 +- lib/features/feed/bloc/feed_bloc.dart | 87 +++ lib/features/feed/bloc/feed_event.dart | 30 + lib/features/feed/bloc/feed_state.dart | 73 +++ lib/features/feed/data/feed_repository.dart | 40 ++ .../feed/presentation/widgets/facet_text.dart | 210 +++++++ .../feed/presentation/widgets/post_card.dart | 503 +++++++++++++++ lib/features/profile/bloc/profile_bloc.dart | 45 ++ lib/features/profile/bloc/profile_event.dart | 20 + lib/features/profile/bloc/profile_state.dart | 42 ++ .../profile/data/profile_repository.dart | 63 ++ .../profile/presentation/profile_screen.dart | 442 ++++++++++++- lib/main.dart | 77 ++- pubspec.lock | 8 + pubspec.yaml | 1 + test/features/feed/bloc/feed_bloc_test.dart | 100 +++ .../feed/presentation/post_card_test.dart | 82 +++ .../presentation/profile_screen_test.dart | 113 ++++ 24 files changed, 2614 insertions(+), 43 deletions(-) create mode 100644 docs/designs/logs.html create mode 100644 lib/features/feed/bloc/feed_bloc.dart create mode 100644 lib/features/feed/bloc/feed_event.dart create mode 100644 lib/features/feed/bloc/feed_state.dart create mode 100644 lib/features/feed/data/feed_repository.dart create mode 100644 lib/features/feed/presentation/widgets/facet_text.dart create mode 100644 lib/features/feed/presentation/widgets/post_card.dart create mode 100644 lib/features/profile/bloc/profile_bloc.dart create mode 100644 lib/features/profile/bloc/profile_event.dart create mode 100644 lib/features/profile/bloc/profile_state.dart create mode 100644 lib/features/profile/data/profile_repository.dart create mode 100644 test/features/feed/bloc/feed_bloc_test.dart create mode 100644 test/features/feed/presentation/post_card_test.dart create mode 100644 test/features/profile/presentation/profile_screen_test.dart diff --git a/docs/designs/logs.html b/docs/designs/logs.html new file mode 100644 index 0000000..5e0587a --- /dev/null +++ b/docs/designs/logs.html @@ -0,0 +1,580 @@ + + + + + + Logs - Lazurite + + + + + + + + +
+ +
+ +

Logs

+
+ + +
+
+ +
+ + + + +
+ + + + + + +
+ + +
+
+ 14:32:01.123 + I +
+
App started — session abc123
+
AppLogger
+
+
+ +
+ 14:32:01.456 + I +
+
OAuth session restored for did:plc:z72i7hdy...
+
AuthBloc
+
+
+ +
+ 14:32:02.012 + I +
+
Route pushed: /home
+
NavObserver
+
+
+ +
+ 14:32:02.340 + W +
+
Timeline fetch retry (attempt 2/3) — SocketException: Connection reset
+
FeedBloc
+
+
+ +
+ 14:32:03.891 + I +
+
GET /xrpc/app.bsky.feed.getTimeline — 200 (342ms)
+
HttpLogger
+
+
+ +
+ 14:32:05.220 + E +
+
+ Failed to decode feed post: type 'Null' is not a subtype of type 'String' in type cast #0 + FeedRepository.decodeFeedViewPost (feed_repository.dart:142) #1 FeedBloc._onTimelineRequested + (feed_bloc.dart:58) +
+
FeedBloc
+
+
+ +
+ 14:32:06.100 + I +
+
Route pushed: /profile/did:plc:z72i7hdy...
+
NavObserver
+
+
+ +
+ 14:32:06.540 + I +
+
GET /xrpc/app.bsky.actor.getProfile — 200 (198ms)
+
HttpLogger
+
+
+ +
+ 14:32:07.010 + W +
+
Image cache miss for avatar CDN — falling back to network fetch
+
ImageCache
+
+
+ +
+ 14:32:08.330 + I +
+
ProfileBloc transition: ProfileLoading → ProfileLoaded
+
BlocObserver
+
+
+ +
+ 14:32:12.450 + F +
+
Unhandled exception in zone — FormatException: Invalid JSON at position 0
+
AppLogger
+
+
+
+ + +
+ + + + Auto-scroll +
+
+ + + +
+ + + + diff --git a/docs/designs/settings.html b/docs/designs/settings.html index 8b83f27..2887337 100644 --- a/docs/designs/settings.html +++ b/docs/designs/settings.html @@ -456,6 +456,27 @@ + + +
+ + + + + + + +
+
Logs
+
View app log files
+
+
+
+ + + +
+
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); + }); +} -- 2.51.2