diff --git a/.github/workflows/flutter_lint.yml b/.github/workflows/flutter_lint.yml index 3ed0d9db..351c2ab6 100644 --- a/.github/workflows/flutter_lint.yml +++ b/.github/workflows/flutter_lint.yml @@ -3,7 +3,11 @@ name: Flutter Analyze Changed Files on: pull_request: branches: [main] - types: [opened, synchronize, reopened] # Run when PR is opened, updated, or reopened + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write jobs: lint: @@ -22,89 +26,52 @@ jobs: files: | **/*.dart + - name: Get changed widgetbook Dart files + id: widgetbook_changed_files + uses: tj-actions/changed-files@v46 + with: + files: | + widgetbook/**/*.dart + - name: Set up Flutter - if: steps.changed_files.outputs.all_changed_files != '' + if: steps.changed_files.outputs.any_changed == 'true' uses: subosito/flutter-action@v2 with: channel: "stable" - flutter-version: 3.35.7 + flutter-version: 3.38.4 cache: true - - name: Generated code setup - if: steps.changed_files.outputs.all_changed_files != '' + - name: Codegen (app) + if: steps.changed_files.outputs.any_changed == 'true' run: | - flutter pub get + touch .env + flutter pub get --enforce-lockfile dart run build_runner build --delete-conflicting-outputs - - name: Run Flutter Analyze - id: analyze_step - if: steps.changed_files.outputs.all_changed_files != '' + - name: Codegen (widgetbook) + if: steps.widgetbook_changed_files.outputs.any_changed == 'true' run: | - echo "Changed Dart files:" - echo "${{ steps.changed_files.outputs.all_changed_files }}" - - FILTERED_FILES=$(printf "%s\n" "${{ steps.changed_files.outputs.all_changed_files }}" | tr ' ' '\n' | grep -v '^widgetbook/' | tr '\n' ' ' | sed 's/[[:space:]]*$//') - - if [ -z "$FILTERED_FILES" ]; then - echo "Only widgetbook files changed. Skipping flutter analyze." - exit 0 - fi - - echo "Analyzing:" - echo "$FILTERED_FILES" - # Run analyze and save output. Redirect stderr to stdout for capture. - # Continue with '|| true' so the workflow doesn't stop if issues are found, - # as we want to report them as annotations. - flutter analyze $FILTERED_FILES > flutter_analyze_output.txt 2>&1 || true - - echo "--- Flutter Analyze Output (raw) ---" - cat flutter_analyze_output.txt - echo "------------------------------------" + cd widgetbook + dart run build_runner build --delete-conflicting-outputs - - name: Process Analyze Output, Annotate & Check Errors - if: steps.changed_files.outputs.all_changed_files != '' + - name: Determine analyze targets + id: analyze_targets + if: steps.changed_files.outputs.any_changed == 'true' run: | - if [ ! -s flutter_analyze_output.txt ]; then - echo "::debug::flutter_analyze_output.txt not found or is empty. No annotations to create or errors to check." - exit 0 + if [ "${{ steps.widgetbook_changed_files.outputs.any_changed }}" = "true" ]; then + echo "targets=." >> "$GITHUB_OUTPUT" + else + echo "targets=lib test" >> "$GITHUB_OUTPUT" fi - echo "::debug::Starting annotation processing and error checking from flutter_analyze_output.txt" - found_any_issue="false" # Flag to track if any issues (info, warning, error) are found - - while IFS= read -r line; do - if [[ "$line" =~ ^[[:space:]]*(info|warning|error)[[:space:]]+•[[:space:]]+(.+)[[:space:]]+•[[:space:]]+([^:]+):([0-9]+):([0-9]+)[[:space:]]+•[[:space:]]+(.+)$ ]]; then - found_any_issue="true" # Set flag if any issue is found - type="${BASH_REMATCH[1]}" - message_body="${BASH_REMATCH[2]}" - file_path="${BASH_REMATCH[3]}" - line_num="${BASH_REMATCH[4]}" - col_num="${BASH_REMATCH[5]}" - rule_id="${BASH_REMATCH[6]}" - - github_level="notice" # Default for 'info' - if [ "$type" = "warning" ]; then - github_level="warning" - elif [ "$type" = "error" ]; then - github_level="error" - fi - - message_body_escaped="${message_body//'%'/'%25'}" - message_body_escaped="${message_body_escaped//$'\r'/'%0D'}" - message_body_escaped="${message_body_escaped//$'\n'/'%0A'}" - - echo "::$github_level file=$file_path,line=$line_num,col=$col_num,title=$rule_id::$message_body_escaped" - elif [[ "$line" =~ issues\ found\.|\ Analyzing\ |^$|No\ issues\ found!|Looking\ for\ direct\ dependencies\ of|Running\ \"flutter\ pub\ get\"\ in ]]; then - echo "::debug::Skipping known non-issue line: $line" - else - echo "::debug::Skipping unparseable line from flutter_analyze_output.txt: $line" - fi - done < flutter_analyze_output.txt - echo "::debug::Finished annotation processing." + - name: Run Flutter Analyze + id: analyze_step + if: steps.changed_files.outputs.any_changed == 'true' + run: flutter analyze --write=flutter_analyze.log ${{ steps.analyze_targets.outputs.targets }} - if [ "$found_any_issue" = "true" ]; then - echo "::error::Flutter analyze reported issues. See annotations for details. Failing workflow." - exit 1 - else - echo "::debug::No flutter analyze issues found. Workflow will pass." - fi + - name: Flutter Analyze Commenter + if: ${{ !cancelled() && steps.changed_files.outputs.any_changed == 'true' }} # Run only when analyze executed + uses: yorifuji/flutter-analyze-commenter@v1 + with: + analyze-log: flutter_analyze.log + verbose: false diff --git a/.gitignore b/.gitignore index 4729a06b..dc6f2440 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/build/ # Environment variables .env diff --git a/lib/src/core/auth/data/models/identity_info.dart b/lib/src/core/auth/data/models/identity_info.dart index 1bb9ff0c..0953d028 100644 --- a/lib/src/core/auth/data/models/identity_info.dart +++ b/lib/src/core/auth/data/models/identity_info.dart @@ -5,7 +5,7 @@ part 'identity_info.g.dart'; /// Represents identity information in the AT Protocol @freezed -class IdentityInfo with _$IdentityInfo { +abstract class IdentityInfo with _$IdentityInfo { const factory IdentityInfo({ /// Decentralized Identifier (DID) required String did, diff --git a/lib/src/core/auth/data/models/login_result.dart b/lib/src/core/auth/data/models/login_result.dart index b5b36837..6f472422 100644 --- a/lib/src/core/auth/data/models/login_result.dart +++ b/lib/src/core/auth/data/models/login_result.dart @@ -5,7 +5,7 @@ part 'login_result.freezed.dart'; /// Result of a login attempt @freezed -class LoginResult with _$LoginResult { +abstract class LoginResult with _$LoginResult { const factory LoginResult({ required LoginStatus status, String? error, diff --git a/lib/src/core/auth/data/models/onboarding_screen_state.dart b/lib/src/core/auth/data/models/onboarding_screen_state.dart index fcaaedd5..70dbc917 100644 --- a/lib/src/core/auth/data/models/onboarding_screen_state.dart +++ b/lib/src/core/auth/data/models/onboarding_screen_state.dart @@ -1,15 +1,15 @@ import 'dart:typed_data'; -import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/app_bsky_actor_profile.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'onboarding_screen_state.freezed.dart'; @freezed -class OnboardingScreenState with _$OnboardingScreenState { +abstract class OnboardingScreenState with _$OnboardingScreenState { const factory OnboardingScreenState({ @Default(true) bool isLoading, - ProfileRecord? bskyProfileRecord, + ActorProfileRecord? bskyProfileRecord, String? initialAvatarCid, String? initialAvatarUrl, Uint8List? localAvatarBytes, diff --git a/lib/src/core/auth/data/repositories/onboarding_repository.dart b/lib/src/core/auth/data/repositories/onboarding_repository.dart index 1c5be4e4..bdbbb65d 100644 --- a/lib/src/core/auth/data/repositories/onboarding_repository.dart +++ b/lib/src/core/auth/data/repositories/onboarding_repository.dart @@ -1,4 +1,4 @@ -import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/app_bsky_actor_profile.dart'; import 'package:sparksocial/src/core/network/atproto/data/models/graph_models.dart'; abstract class OnboardingRepository { @@ -6,7 +6,7 @@ abstract class OnboardingRepository { Future hasSparkProfile(); /// Retrieves the Bluesky profile for import - Future getBskyProfile(); + Future getBskyProfile(); /// Creates a Spark actor profile with custom values Future createSparkProfile({required String displayName, required String description, dynamic avatar}); diff --git a/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart b/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart index b2a2ee47..e35b2593 100644 --- a/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:atproto/atproto.dart'; import 'package:atproto/core.dart'; +import 'package:bluesky/app_bsky_actor_profile.dart'; import 'package:bluesky/bluesky.dart' as bs; import 'package:get_it/get_it.dart'; import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; @@ -30,8 +31,8 @@ class OnboardingRepositoryImpl implements OnboardingRepository { final uri = AtUri.parse('at://${_session!.did}/so.sprk.actor.profile/self'); try { final response = await _repoRepository.getRecord(uri: uri); - _logger.i('Spark profile found: ${response.record.value}'); - return response.record.value.isNotEmpty; + _logger.i('Spark profile found: ${response.record.toJson()}'); + return response.record.toJson().isNotEmpty; } catch (e) { // Treat 404 and 'Could not locate record' 400 errors as no profile final msg = e.toString().toLowerCase(); @@ -44,13 +45,13 @@ class OnboardingRepositoryImpl implements OnboardingRepository { } @override - Future getBskyProfile() async { + Future getBskyProfile() async { if (_session == null) return null; try { final uri = AtUri.parse('at://${_session!.did}/app.bsky.actor.profile/self'); final response = await _repoRepository.getRecord(uri: uri); - return bs.ProfileRecord.fromJson(response.record.value); + return ActorProfileRecord.fromJson(response.record.toJson()); } catch (e) { _logger.i('Bluesky profile not found', error: e); return null; @@ -93,7 +94,7 @@ class OnboardingRepositoryImpl implements OnboardingRepository { }; await _repoRepository.createRecord( - collection: NSID.parse('so.sprk.actor.profile'), + collection: 'so.sprk.actor.profile', record: record, rkey: 'self', ); @@ -130,7 +131,7 @@ class OnboardingRepositoryImpl implements OnboardingRepository { 'createdAt': DateTime.now().toUtc().toIso8601String(), }; - final response = await _repoRepository.createRecord(collection: NSID.parse('so.sprk.graph.follow'), record: record); + final response = await _repoRepository.createRecord(collection: 'so.sprk.graph.follow', record: record); if (response.uri.toString().isEmpty) { throw Exception('Failed to create Spark follow'); diff --git a/lib/src/core/media/create_media_actions.dart b/lib/src/core/media/create_media_actions.dart index 27709495..be46d791 100644 --- a/lib/src/core/media/create_media_actions.dart +++ b/lib/src/core/media/create_media_actions.dart @@ -35,11 +35,7 @@ class CreateMediaActions { final result = await GetIt.I().openVideoEditor(context, editorVideo); if (result != null && context.mounted) { await context.router.push( - VideoReviewRoute( - videoPath: result.video.path, - storyMode: storyMode, - soundRef: result.soundRef, - ), + VideoReviewRoute(videoPath: result.video.path, storyMode: storyMode, soundRef: result.soundRef), ); } } diff --git a/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart b/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart index 8d5372cd..1d52d9db 100644 --- a/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart +++ b/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart @@ -1,10 +1,11 @@ -import 'dart:convert'; - import 'package:atproto_core/atproto_core.dart'; -import 'package:bluesky/bluesky.dart' as bsky; +import 'package:bluesky/app_bsky_embed_images.dart'; +import 'package:bluesky/app_bsky_feed_getpostthread.dart'; +import 'package:bluesky/app_bsky_feed_post.dart'; +import 'package:bluesky/app_bsky_richtext_facet.dart'; // ignore: implementation_imports -import 'package:bluesky/src/services/entities/converter/embed_converter.dart'; -import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/models.dart' hide ReplyRef; +import 'package:sparksocial/src/core/utils/json_utils.dart'; /// Adapter for Bluesky feed models <-> Spark feed models /// @@ -103,11 +104,11 @@ class BskyFeedAdapter { if (rootType == 'com.atproto.repo.strongRef') { // Leave as is } else if (rootType == 'app.bsky.feed.defs#postView') { - final postViewData = jsonDecode(jsonEncode(root)) as Map; - postViewData.remove(r'$type'); + final postViewData = deepCopyJson(root)..remove(r'$type'); convertPostViewJson(postViewData, isNestedReply: true); - root.removeWhere((key, value) => key != r'$type'); - root['post'] = postViewData; + root + ..clear() + ..addAll({r'$type': rootType, 'post': postViewData}); } else if (rootType == 'app.bsky.feed.defs#notFoundPost' || rootType == 'app.bsky.feed.defs#blockedPost') { // Already in correct format } else if (root.containsKey('post')) { @@ -125,11 +126,11 @@ class BskyFeedAdapter { if (parentType == 'com.atproto.repo.strongRef') { // Leave as is } else if (parentType == 'app.bsky.feed.defs#postView') { - final postViewData = jsonDecode(jsonEncode(parent)) as Map; - postViewData.remove(r'$type'); + final postViewData = deepCopyJson(parent)..remove(r'$type'); convertPostViewJson(postViewData, isNestedReply: true); - parent.removeWhere((key, value) => key != r'$type'); - parent['post'] = postViewData; + parent + ..clear() + ..addAll({r'$type': parentType, 'post': postViewData}); } else if (parentType == 'app.bsky.feed.defs#notFoundPost' || parentType == 'app.bsky.feed.defs#blockedPost') { // Already in correct format } else if (parent.containsKey('post')) { @@ -165,68 +166,100 @@ class BskyFeedAdapter { // ============================================================================ /// Convert Spark images to Bluesky images - List convertImages(List sparkImages) { + List convertImages(List sparkImages) { return sparkImages.map((sparkImage) { - return bsky.Image( + return EmbedImagesImage( alt: sparkImage.alt ?? '', image: sparkImage.image, ); }).toList(); } + /// Convert Spark Media JSON to Bluesky embed format + /// Returns null if media is null or not supported for Bluesky + UFeedPostEmbed? convertJsonToBskyEmbed(Map mediaJson) { + final media = Media.fromJson(mediaJson); + + switch (media) { + case MediaImage(:final image, :final alt): + // Convert single Spark image to Bluesky embed images + final bskyImage = EmbedImagesImage( + alt: alt ?? '', + image: image, + ); + return UFeedPostEmbed.embedImages(data: EmbedImages(images: [bskyImage])); + + case MediaImages(:final images): + // Convert multiple Spark images to Bluesky embed images + final bskyImages = convertImages(images); + return UFeedPostEmbed.embedImages(data: EmbedImages(images: bskyImages)); + + case MediaBskyImages(:final images): + // Already in Bluesky format, convert to embed + final bskyImages = convertImages(images); + return UFeedPostEmbed.embedImages(data: EmbedImages(images: bskyImages)); + + case MediaVideo(): + case MediaBskyVideo(): + case MediaBskyRecord(): + case MediaBskyRecordWithMedia(): + case MediaBskyExternal(): + // Videos and other embed types are not supported for comments/replies + return null; + } + } + /// Create a Bluesky post record - bsky.PostRecord createPostRecord({ + FeedPostRecord createPostRecord({ required String text, required DateTime createdAt, - List? images, - List? facets, + List? images, + List? facets, }) { - return bsky.PostRecord( + return FeedPostRecord( text: text, createdAt: createdAt, - embed: images != null && images.isNotEmpty ? bsky.Embed.images(data: bsky.EmbedImages(images: images)) : null, + embed: images != null && images.isNotEmpty ? UFeedPostEmbed.embedImages(data: EmbedImages(images: images)) : null, facets: facets, ); } /// Create Bluesky comment/reply record - bsky.PostRecord createCommentRecord({ + FeedPostRecord createCommentRecord({ required String text, required DateTime createdAt, - required bsky.ReplyRef reply, - bsky.Embed? embed, + required RecordReplyRef reply, + UFeedPostEmbed? embed, }) { - return bsky.PostRecord( + return FeedPostRecord( text: text, createdAt: createdAt, - reply: reply, + reply: ReplyRef( + root: reply.root, + parent: reply.parent, + ), embed: embed, ); } /// Create a link facet for Bluesky posts - bsky.Facet createLinkFacet({ + RichtextFacet createLinkFacet({ required String linkUrl, required int byteStart, }) { - return bsky.Facet( - index: bsky.ByteSlice(byteStart: byteStart, byteEnd: byteStart + linkUrl.length), - features: [bsky.FacetFeature.link(data: bsky.FacetLink(uri: linkUrl))], + return RichtextFacet( + index: RichtextFacetByteSlice(byteStart: byteStart, byteEnd: byteStart + linkUrl.length), + features: [URichtextFacetFeatures.richtextFacetLink(data: RichtextFacetLink(uri: linkUrl))], ); } /// Convert Bluesky thread to Spark thread Thread convertBskyThreadToSparkThread({ - required bsky.PostThreadView thread, + required UFeedGetPostThreadThread thread, required AtUri uri, }) { return Thread.fromBsky(thread: thread, uri: uri); } - - /// Convert media JSON to Bluesky embed - bsky.Embed? convertJsonToBskyEmbed(Map mediaJson) { - return embedConverter.fromJson(mediaJson); - } } /// Singleton instance of the Bluesky feed adapter diff --git a/lib/src/core/network/atproto/data/models/actor_models.dart b/lib/src/core/network/atproto/data/models/actor_models.dart index 7ebcd767..694a04bc 100644 --- a/lib/src/core/network/atproto/data/models/actor_models.dart +++ b/lib/src/core/network/atproto/data/models/actor_models.dart @@ -1,4 +1,5 @@ -import 'package:atproto/atproto.dart'; +import 'package:atproto/com_atproto_label_defs.dart'; +import 'package:atproto/com_atproto_repo_strongref.dart'; import 'package:atproto_core/atproto_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:sparksocial/src/core/utils/uri_converter.dart'; @@ -7,7 +8,7 @@ part 'actor_models.freezed.dart'; part 'actor_models.g.dart'; @freezed -class ActorViewer with _$ActorViewer { +abstract class ActorViewer with _$ActorViewer { @JsonSerializable(explicitToJson: true) const factory ActorViewer({ bool? muted, @@ -25,7 +26,7 @@ class ActorViewer with _$ActorViewer { } @freezed -class KnownFollowers with _$KnownFollowers { +abstract class KnownFollowers with _$KnownFollowers { @JsonSerializable(explicitToJson: true) const factory KnownFollowers({ required int count, @@ -43,7 +44,7 @@ class KnownFollowers with _$KnownFollowers { } @freezed -class ProfileViewBasic with _$ProfileViewBasic { +abstract class ProfileViewBasic with _$ProfileViewBasic { @JsonSerializable(explicitToJson: true) const factory ProfileViewBasic({ required String did, @@ -52,7 +53,7 @@ class ProfileViewBasic with _$ProfileViewBasic { @UriConverter() Uri? avatar, // associated: lists, feedgens, starterpacks, labelers, chat?? not needed for now ActorViewer? viewer, - List? stories, + List? stories, }) = _ProfileViewBasic; const ProfileViewBasic._(); @@ -60,7 +61,7 @@ class ProfileViewBasic with _$ProfileViewBasic { } @freezed -class ProfileView with _$ProfileView { +abstract class ProfileView with _$ProfileView { @JsonSerializable(explicitToJson: true) const factory ProfileView({ required String did, @@ -103,7 +104,7 @@ class SearchActorsResponse { } @freezed -class ProfileViewDetailed with _$ProfileViewDetailed { +abstract class ProfileViewDetailed with _$ProfileViewDetailed { @JsonSerializable(explicitToJson: true) const factory ProfileViewDetailed({ required String did, @@ -120,8 +121,8 @@ class ProfileViewDetailed with _$ProfileViewDetailed { // indexedAt and createdAt ActorViewer? viewer, List