From 813d52325276d2bae44e04a9b4fb6d2bc43b6543 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg <118622417+knotbin@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:32:06 -0500 Subject: [PATCH] Custom feeds (#96) --- .../atoms/buttons/accent_button.dart | 18 +- .../atoms/toggles/follow_button.dart | 18 +- .../atoms/toggles/glass_follow_button.dart | 2 +- .../components/molecules/feed_card.dart | 288 +++++++ .../components/molecules/glass_input.dart | 1 - .../molecules/profile_action_buttons.dart | 6 +- ...ggested_profile.dart => profile_card.dart} | 104 ++- .../molecules/settings_feed_card.dart | 266 +++++++ .../components/organisms/bottom_nav_bar.dart | 15 +- .../core/design_system/theme/app_theme.dart | 3 + lib/src/core/design_system/tokens/shapes.dart | 2 +- lib/src/core/di/service_locator.dart | 14 +- .../hardcoded_feed_algorithm.dart | 48 -- .../atproto/data/models/actor_models.dart | 7 - .../atproto/data/models/feed_models.dart | 90 +-- .../atproto/data/models/labeler_models.dart | 5 +- .../network/atproto/data/models/models.dart | 1 + .../atproto/data/models/pref_models.dart | 249 ++++++ .../data/repositories/actor_repository.dart | 10 - .../repositories/actor_repository_impl.dart | 64 -- .../data/repositories/feed_repository.dart | 16 + .../repositories/feed_repository_impl.dart | 243 +++++- .../data/repositories/pref_repository.dart | 12 + .../repositories/pref_repository_impl.dart | 76 ++ lib/src/core/routing/pages.dart | 1 - .../storage/cache/download_manager_impl.dart | 40 +- .../core/storage/cache/sql_cache_impl.dart | 731 ------------------ .../storage/cache/sql_cache_interface.dart | 108 --- .../preferences/default_preferences.dart | 85 ++ .../preferences/settings_repository.dart | 27 - .../preferences/settings_repository_impl.dart | 373 --------- .../preferences/storage_constants.dart | 7 +- lib/src/core/storage/storage.dart | 1 - lib/src/core/utils/label_utils.dart | 93 ++- .../features/auth/ui/pages/login_page.dart | 7 + .../auth/ui/pages/onboarding_page.dart | 7 + .../features/auth/ui/pages/register_page.dart | 7 + .../providers/comments_page_provider.dart | 6 - .../feed/providers/feed_provider.dart | 164 ++-- .../features/feed/providers/feed_state.dart | 4 +- lib/src/features/feed/ui/pages/feed_page.dart | 6 +- .../features/feed/ui/pages/feeds_page.dart | 5 +- .../feed/ui/widgets/feed/feeds_bar.dart | 15 +- .../ui/pages/new_chat_search_page.dart | 13 +- .../posting/ui/pages/image_review_page.dart | 12 +- .../posting/ui/pages/video_review_page.dart | 10 +- .../providers/profile_feed_provider.dart | 21 +- .../ui/widgets/profile_feed_post_widget.dart | 24 - .../profile/ui/widgets/user_list_view.dart | 18 +- .../providers/suggested_feeds_provider.dart | 46 ++ .../search/ui/pages/post_results.dart | 25 +- .../features/search/ui/pages/search_page.dart | 4 + .../search/ui/pages/user_results.dart | 20 +- .../features/search/ui/widgets/post_card.dart | 188 ----- .../ui/widgets/suggested_account_card.dart | 43 -- .../ui/widgets/suggested_feeds_list.dart | 161 ++++ .../settings/providers/settings_provider.dart | 368 ++++++++- .../settings/providers/settings_state.dart | 11 +- .../settings/ui/pages/feed_list_page.dart | 409 +++++----- .../ui/pages/label_settings_page.dart | 30 +- .../splash/providers/splash_providers.dart | 37 - .../splash/providers/splash_state.dart | 13 - .../features/splash/ui/pages/splash_page.dart | 206 ----- lib/src/sprk_app.dart | 2 +- ...ggested_profile.dart => profile_card.dart} | 8 +- widgetbook/lib/widgetbook.dart | 12 +- 66 files changed, 2404 insertions(+), 2522 deletions(-) create mode 100644 lib/src/core/design_system/components/molecules/feed_card.dart rename lib/src/core/design_system/components/molecules/{suggested_profile.dart => profile_card.dart} (51%) create mode 100644 lib/src/core/design_system/components/molecules/settings_feed_card.dart delete mode 100644 lib/src/core/feed_algorithms/hardcoded_feed_algorithm.dart create mode 100644 lib/src/core/network/atproto/data/models/pref_models.dart create mode 100644 lib/src/core/network/atproto/data/repositories/pref_repository.dart create mode 100644 lib/src/core/network/atproto/data/repositories/pref_repository_impl.dart delete mode 100644 lib/src/core/storage/cache/sql_cache_impl.dart delete mode 100644 lib/src/core/storage/cache/sql_cache_interface.dart create mode 100644 lib/src/core/storage/preferences/default_preferences.dart delete mode 100644 lib/src/core/storage/preferences/settings_repository.dart delete mode 100644 lib/src/core/storage/preferences/settings_repository_impl.dart create mode 100644 lib/src/features/search/providers/suggested_feeds_provider.dart delete mode 100644 lib/src/features/search/ui/widgets/post_card.dart delete mode 100644 lib/src/features/search/ui/widgets/suggested_account_card.dart create mode 100644 lib/src/features/search/ui/widgets/suggested_feeds_list.dart delete mode 100644 lib/src/features/splash/providers/splash_providers.dart delete mode 100644 lib/src/features/splash/providers/splash_state.dart delete mode 100644 lib/src/features/splash/ui/pages/splash_page.dart rename widgetbook/lib/molecules/{suggested_profile.dart => profile_card.dart} (80%) diff --git a/lib/src/core/design_system/components/atoms/buttons/accent_button.dart b/lib/src/core/design_system/components/atoms/buttons/accent_button.dart index 85c72d8d..cc35c3a6 100644 --- a/lib/src/core/design_system/components/atoms/buttons/accent_button.dart +++ b/lib/src/core/design_system/components/atoms/buttons/accent_button.dart @@ -21,9 +21,9 @@ class AccentButton extends StatelessWidget { onTap: onPressed, borderRadius: BorderRadius.circular(500), child: Container( - constraints: BoxConstraints.tightFor(height: 30, width: label.length * 8 + 16), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 8), + constraints: BoxConstraints.tightFor(height: 30, width: label.length * 10 + 20), + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( gradient: AppGradients.accent, borderRadius: BorderRadius.circular(500), // pill shape @@ -32,11 +32,13 @@ class AccentButton extends StatelessWidget { width: 2, ), ), - child: Text( - label, - textAlign: TextAlign.center, - style: AppTypography.textSmallBold.copyWith( - color: AppColors.greyWhite, + child: Align( + child: Text( + label, + textAlign: TextAlign.center, + style: AppTypography.textSmallBold.copyWith( + color: AppColors.greyWhite, + ), ), ), ), diff --git a/lib/src/core/design_system/components/atoms/toggles/follow_button.dart b/lib/src/core/design_system/components/atoms/toggles/follow_button.dart index 9047ea50..6717f489 100644 --- a/lib/src/core/design_system/components/atoms/toggles/follow_button.dart +++ b/lib/src/core/design_system/components/atoms/toggles/follow_button.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:sparksocial/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; import 'package:sparksocial/src/core/design_system/tokens/colors.dart'; -import 'package:sparksocial/src/core/design_system/tokens/gradients.dart'; import 'package:sparksocial/src/core/design_system/tokens/typography.dart'; class FollowButton extends StatelessWidget { @@ -12,6 +11,7 @@ class FollowButton extends StatelessWidget { super.key, this.followText = 'Follow', this.unfollowText = 'Unfollow', + this.width, }); final bool isFollowing; @@ -19,6 +19,7 @@ class FollowButton extends StatelessWidget { final VoidCallback onUnfollow; final String followText; final String unfollowText; + final double? width; @override Widget build(BuildContext context) { @@ -28,14 +29,10 @@ class FollowButton extends StatelessWidget { onTap: isFollowing ? onUnfollow : onFollow, borderRadius: const BorderRadius.all(Radius.circular(8)), child: Container( - width: 109.47, + width: width ?? 109.47, height: 36, decoration: isFollowing - ? const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8)), - gradient: AppGradients.accent, - ) - : BoxDecoration( + ? BoxDecoration( color: isDark ? AppColors.darkGreyButton : AppColors.lightGreyButton, borderRadius: const BorderRadius.all(Radius.circular(8)), border: const Border.fromBorderSide( @@ -44,9 +41,12 @@ class FollowButton extends StatelessWidget { width: 1.14667, ), ), + ) + : const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + color: AppColors.primary500, ), - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), - child: Center( + child: Align( child: Text( // The text depends on the current state. isFollowing ? unfollowText : followText, diff --git a/lib/src/core/design_system/components/atoms/toggles/glass_follow_button.dart b/lib/src/core/design_system/components/atoms/toggles/glass_follow_button.dart index d8222403..30cc852e 100644 --- a/lib/src/core/design_system/components/atoms/toggles/glass_follow_button.dart +++ b/lib/src/core/design_system/components/atoms/toggles/glass_follow_button.dart @@ -27,7 +27,7 @@ class GlassFollowButton extends StatelessWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: Container( - constraints: const BoxConstraints(maxWidth: 60), + constraints: const BoxConstraints(maxWidth: 80, maxHeight: 30), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.white.withAlpha(51), diff --git a/lib/src/core/design_system/components/molecules/feed_card.dart b/lib/src/core/design_system/components/molecules/feed_card.dart new file mode 100644 index 00000000..8d15a7e3 --- /dev/null +++ b/lib/src/core/design_system/components/molecules/feed_card.dart @@ -0,0 +1,288 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:sparksocial/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:sparksocial/src/core/design_system/tokens/colors.dart'; +import 'package:sparksocial/src/core/design_system/tokens/shapes.dart'; +import 'package:sparksocial/src/core/design_system/tokens/typography.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; + +class FeedCard extends StatelessWidget { + const FeedCard({ + required this.feed, + required this.isAdded, + required this.onAdd, + required this.onPin, + required this.onUnpin, + this.onTap, + this.showActionButton = true, + super.key, + }); + + final Feed feed; + final bool isAdded; + final VoidCallback onAdd; + final VoidCallback onPin; + final VoidCallback onUnpin; + final VoidCallback? onTap; + final bool showActionButton; + + GeneratorView? get generator => feed.view; + + bool get isPinned => feed.config.pinned; + + bool get _isTimeline => feed.type == 'timeline'; + + String get _title { + if (generator != null) { + return generator!.displayName; + } + return _isTimeline ? 'Following' : feed.config.value; + } + + String? get _subtitle { + if (generator != null) { + return 'by @${generator!.creator.handle}'; + } + if (_isTimeline) { + return 'Posts from people you follow'; + } + return null; + } + + String? get _description => generator?.description; + + String get _avatarUrl { + if (generator?.avatar != null) { + return generator!.avatar.toString(); + } + return ''; + } + + bool get _isPrimaryAction => !isAdded || !isPinned; + + String get _actionLabel { + if (!isAdded) return 'Add feed'; + if (isPinned) return 'Unpin feed'; + return 'Pin feed'; + } + + VoidCallback get _actionCallback { + if (!isAdded) return onAdd; + if (isPinned) return onUnpin; + return onPin; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final radius = BorderRadius.circular(AppShapes.squircleRadius); + final borderColor = isDark ? AppColors.grey800 : AppColors.grey200; + + final Widget content = ConstrainedBox( + constraints: const BoxConstraints(minHeight: 60), + child: Material( + color: isDark ? AppColors.grey700 : AppColors.grey100, + shape: RoundedSuperellipseBorder(borderRadius: radius), + clipBehavior: Clip.antiAlias, + child: Container( + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder( + side: BorderSide(color: borderColor), + borderRadius: radius, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FeedAvatar(imageUrl: _avatarUrl), + const SizedBox(width: 10), + Expanded(child: _buildTextContent(context)), + ], + ), + ), + if (showActionButton) ...[ + const SizedBox(width: 8), + _buildActionButton(context), + ], + ], + ), + ), + ), + ), + ); + + if (onTap != null) { + return GestureDetector( + onTap: onTap, + child: content, + ); + } + + return content; + } + + Widget _buildTextContent(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_title, style: AppTypography.textSmallBold, overflow: TextOverflow.ellipsis), + if (_subtitle != null) + Text( + _subtitle!, + style: AppTypography.textSmallThin, + overflow: TextOverflow.ellipsis, + ), + if (_description?.isNotEmpty ?? false) ...[ + const SizedBox(height: 3), + Text( + _description!, + style: AppTypography.textExtraSmallThin, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 4), + _buildMetaRow(context), + ], + ); + } + + Widget _buildMetaRow(BuildContext context) { + final likeCount = generator?.likeCount ?? 0; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.favorite, + size: 14, + color: generator?.viewer?.like != null ? AppColors.red500 : (isDark ? AppColors.grey200 : AppColors.grey400), + ), + const SizedBox(width: 4), + Text( + _formatCount(likeCount), + style: AppTypography.textExtraSmallThin, + ), + const SizedBox(width: 8), + ], + ); + } + + Widget _buildActionButton(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final isPrimary = _isPrimaryAction; + final backgroundColor = isPrimary + ? AppColors.primary500 + : isDark + ? AppColors.darkGreyButton + : AppColors.lightGreyButton; + + final textStyle = AppTypography.textSmallMedium.copyWith( + color: isPrimary ? Colors.white : (isDark ? AppColors.grey100 : AppColors.grey900), + ); + + return InteractivePressable( + onTap: _actionCallback, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + constraints: const BoxConstraints(minWidth: 110, minHeight: 36, maxHeight: 36), + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: isPrimary + ? null + : Border.all( + color: isDark ? Colors.white.withValues(alpha: 0.15) : AppColors.grey200, + width: 1.1, + ), + ), + alignment: Alignment.center, + child: Text(_actionLabel, style: textStyle, overflow: TextOverflow.ellipsis), + ), + ); + } + + String _formatCount(int count) { + if (count >= 1000000) { + return '${(count / 1000000).toStringAsFixed(1)}M'; + } else if (count >= 1000) { + return '${(count / 1000).toStringAsFixed(1)}K'; + } + return count.toString(); + } +} + +class _FeedAvatar extends StatelessWidget { + const _FeedAvatar({required this.imageUrl}); + + final String imageUrl; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + if (imageUrl.isEmpty) { + return _FallbackAvatar(isDark: isDark); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: imageUrl, + width: 36, + height: 36, + fit: BoxFit.cover, + placeholder: (context, url) => _FallbackAvatar(isDark: isDark, showLoader: true), + errorWidget: (context, url, error) => _FallbackAvatar(isDark: isDark), + ), + ); + } +} + +class _FallbackAvatar extends StatelessWidget { + const _FallbackAvatar({required this.isDark, this.showLoader = false}); + + final bool isDark; + final bool showLoader; + + @override + Widget build(BuildContext context) { + const background = AppColors.primary600; + const iconColor = AppColors.greyWhite; + + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + ), + child: showLoader + ? const Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary500), + ), + ), + ) + : const Icon( + FluentIcons.feed_24_regular, + color: iconColor, + size: 18, + ), + ); + } +} diff --git a/lib/src/core/design_system/components/molecules/glass_input.dart b/lib/src/core/design_system/components/molecules/glass_input.dart index 0ea04eda..4b0fe13f 100644 --- a/lib/src/core/design_system/components/molecules/glass_input.dart +++ b/lib/src/core/design_system/components/molecules/glass_input.dart @@ -126,7 +126,6 @@ class GlassInput extends StatelessWidget { icon: AppIcons.send(size: 18, color: isDark ? Colors.white : Colors.black), ), ], - // TODO: record features https://pub.dev/packages/waveform_flutter https://pub.dev/packages/record ], ), ), diff --git a/lib/src/core/design_system/components/molecules/profile_action_buttons.dart b/lib/src/core/design_system/components/molecules/profile_action_buttons.dart index 5d68e584..1df1eadf 100644 --- a/lib/src/core/design_system/components/molecules/profile_action_buttons.dart +++ b/lib/src/core/design_system/components/molecules/profile_action_buttons.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:sparksocial/src/core/design_system/components/atoms/buttons/text_button.dart' as ds; -import 'package:sparksocial/src/core/design_system/components/atoms/toggles/glass_follow_button.dart'; +import 'package:sparksocial/src/core/design_system/components/atoms/toggles/follow_button.dart'; class ProfileActionButtons extends StatelessWidget { const ProfileActionButtons({ @@ -45,12 +45,12 @@ class ProfileActionButtons extends StatelessWidget { return Row( children: [ Expanded( - child: GlassFollowButton( + child: FollowButton( isFollowing: isFollowing, onFollow: onFollowTap ?? () {}, onUnfollow: onUnfollowTap ?? () {}, - followText: 'Follow', unfollowText: 'Following', + width: double.infinity, // Stretch across available space like before ), ), const SizedBox(width: 8), diff --git a/lib/src/core/design_system/components/molecules/suggested_profile.dart b/lib/src/core/design_system/components/molecules/profile_card.dart similarity index 51% rename from lib/src/core/design_system/components/molecules/suggested_profile.dart rename to lib/src/core/design_system/components/molecules/profile_card.dart index b8ec838e..dee46627 100644 --- a/lib/src/core/design_system/components/molecules/suggested_profile.dart +++ b/lib/src/core/design_system/components/molecules/profile_card.dart @@ -5,8 +5,8 @@ import 'package:sparksocial/src/core/design_system/tokens/colors.dart'; import 'package:sparksocial/src/core/design_system/tokens/shapes.dart'; import 'package:sparksocial/src/core/design_system/tokens/typography.dart'; -class SuggestedProfile extends StatelessWidget { - const SuggestedProfile({ +class ProfileCard extends StatelessWidget { + const ProfileCard({ required this.imageUrl, required this.userName, required this.userHandle, @@ -15,11 +15,12 @@ class SuggestedProfile extends StatelessWidget { required this.onUnfollow, this.showFollowButton = true, this.description, + this.onTap, super.key, }); // Convenience constructor to surface description explicitly - const SuggestedProfile.withDescription({ + const ProfileCard.withDescription({ required String imageUrl, required String userName, required String userHandle, @@ -28,6 +29,7 @@ class SuggestedProfile extends StatelessWidget { required VoidCallback onUnfollow, required String description, bool showFollowButton = true, + VoidCallback? onTap, Key? key, }) : this( imageUrl: imageUrl, @@ -38,6 +40,7 @@ class SuggestedProfile extends StatelessWidget { onUnfollow: onUnfollow, showFollowButton: showFollowButton, description: description, + onTap: onTap, key: key, ); @@ -49,6 +52,7 @@ class SuggestedProfile extends StatelessWidget { final VoidCallback onUnfollow; final bool showFollowButton; final String? description; + final VoidCallback? onTap; @override Widget build(BuildContext context) { @@ -56,7 +60,7 @@ class SuggestedProfile extends StatelessWidget { final radius = BorderRadius.circular(AppShapes.squircleRadius); final borderColor = isDark ? AppColors.grey800 : AppColors.grey200; - return ConstrainedBox( + final Widget content = ConstrainedBox( constraints: const BoxConstraints(minHeight: 60), child: Material( color: isDark ? AppColors.grey700 : AppColors.grey100, @@ -73,53 +77,77 @@ class SuggestedProfile extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(7), - child: CachedNetworkImage( - imageUrl: imageUrl, - width: 36, - height: 36, - fit: BoxFit.cover, + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(100), + child: imageUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl, + width: 36, + height: 36, + fit: BoxFit.cover, + ) + : Container( + width: 36, + height: 36, + color: isDark ? AppColors.grey600 : AppColors.grey300, + child: Icon( + Icons.person, + size: 20, + color: isDark ? AppColors.grey400 : AppColors.grey600, + ), + ), ), - ), - const SizedBox(width: 10), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(userName, style: AppTypography.textMediumBold), - const SizedBox(height: 3), - Text(userHandle, style: AppTypography.textExtraSmallThin), - if (description?.isNotEmpty ?? false) ...[ - const SizedBox(height: 3), - SizedBox( - width: 180, - child: Text( - description!, - style: AppTypography.textExtraSmallThin, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ], - ), - ], + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(userName, style: AppTypography.textSmallBold), + Text(userHandle, style: AppTypography.textSmallThin), + if (description?.isNotEmpty ?? false) ...[ + const SizedBox(height: 3), + Text( + description!, + style: AppTypography.textExtraSmallThin, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), ), - if (showFollowButton) + if (showFollowButton) ...[ + const SizedBox(width: 8), FollowButton( isFollowing: isFollowing, onFollow: onFollow, onUnfollow: onUnfollow, ), + ], ], ), ), ), ), ); + + if (onTap != null) { + return GestureDetector( + onTap: onTap, + child: content, + ); + } + + return content; } } diff --git a/lib/src/core/design_system/components/molecules/settings_feed_card.dart b/lib/src/core/design_system/components/molecules/settings_feed_card.dart new file mode 100644 index 00000000..a79f614d --- /dev/null +++ b/lib/src/core/design_system/components/molecules/settings_feed_card.dart @@ -0,0 +1,266 @@ +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:sparksocial/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:sparksocial/src/core/design_system/tokens/colors.dart'; +import 'package:sparksocial/src/core/design_system/tokens/shapes.dart'; +import 'package:sparksocial/src/core/design_system/tokens/typography.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; + +enum SettingsFeedCardMode { + display, + edit, +} + +class SettingsFeedCard extends StatelessWidget { + const SettingsFeedCard({ + required this.feed, + required this.mode, + required this.isActive, + required this.index, + this.onTap, + this.onDelete, + this.onPin, + this.onUnpin, + this.onLike, + this.onUnlike, + super.key, + }); + + final Feed feed; + final SettingsFeedCardMode mode; + final bool isActive; + final int index; + final VoidCallback? onTap; + final VoidCallback? onDelete; + final VoidCallback? onPin; + final VoidCallback? onUnpin; + final VoidCallback? onLike; + final VoidCallback? onUnlike; + + GeneratorView? get generator => feed.view; + + bool get _isTimeline => feed.type == 'timeline'; + + bool get _isLiked => feed.view?.viewer?.like != null; + + String get _title { + if (generator != null) { + return generator!.displayName; + } + return _isTimeline ? 'Following' : feed.config.value; + } + + String? get _subtitle { + if (generator != null) { + return 'by @${generator!.creator.handle}'; + } + if (_isTimeline) { + return 'Posts from people you follow'; + } + return null; + } + + String get _avatarUrl { + if (generator?.avatar != null) { + return generator!.avatar.toString(); + } + return ''; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final radius = BorderRadius.circular(AppShapes.squircleRadius); + final borderColor = isDark ? AppColors.grey800 : AppColors.grey200; + + if (mode == SettingsFeedCardMode.edit) { + return _buildEditMode(context, radius, borderColor); + } + + return _buildDisplayMode(context, radius, borderColor); + } + + Widget _buildDisplayMode(BuildContext context, BorderRadius radius, Color borderColor) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final backgroundColor = isDark ? AppColors.grey700 : AppColors.grey100; + + return Material( + color: backgroundColor, + shape: RoundedRectangleBorder(borderRadius: radius), + clipBehavior: Clip.antiAlias, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: borderColor), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _buildAvatar(), + const SizedBox(width: 12), + Expanded(child: _buildTextContent()), + const SizedBox(width: 8), + _buildLikeButton(), + ], + ), + ), + ), + ); + } + + Widget _buildEditMode(BuildContext context, BorderRadius radius, Color borderColor) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final backgroundColor = isDark ? AppColors.grey700 : AppColors.grey100; + + return Material( + color: backgroundColor, + shape: RoundedRectangleBorder(borderRadius: radius), + clipBehavior: Clip.antiAlias, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: borderColor), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _buildAvatar(), + const SizedBox(width: 12), + Expanded(child: _buildTextContent()), + const SizedBox(width: 8), + _buildEditActions(), + const SizedBox(width: 8), + _buildDragHandle(index), + ], + ), + ), + ), + ); + } + + Widget _buildAvatar() { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: _avatarUrl.isNotEmpty + ? Image.network( + _avatarUrl, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildFallbackAvatar(), + ) + : _buildFallbackAvatar(), + ), + ); + } + + Widget _buildFallbackAvatar() { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary600, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _isTimeline ? FluentIcons.people_24_regular : FluentIcons.feed_24_regular, + size: 20, + color: Colors.white, + ), + ); + } + + Widget _buildTextContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _title, + style: AppTypography.textSmallBold, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + if (_subtitle != null) ...[ + const SizedBox(height: 2), + Text( + _subtitle!, + style: AppTypography.textExtraSmallThin, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ], + ); + } + + Widget _buildDragHandle(int index) { + return ReorderableDragStartListener( + index: index, + child: Icon( + Icons.drag_indicator, + size: 20, + color: Colors.grey.withAlpha(178), + ), + ); + } + + Widget _buildLikeButton() { + if (_isTimeline) return const SizedBox.shrink(); // Don't show like button for timeline + + return InteractivePressable( + onTap: _isLiked ? onUnlike : onLike, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _isLiked ? AppColors.red500 : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: _isLiked ? AppColors.red500 : (Colors.grey.withAlpha(100)), + ), + ), + child: Icon( + Icons.favorite, + size: 16, + color: _isLiked ? Colors.white : Colors.grey, + ), + ), + ); + } + + Widget _buildEditActions() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onDelete != null) ...[ + const SizedBox(width: 8), + InteractivePressable( + onTap: onDelete!, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade500, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.delete, + size: 16, + color: Colors.white, + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart b/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart index b3f7c797..8deb0e8b 100644 --- a/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart +++ b/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart @@ -139,6 +139,7 @@ class _ProfileAvatar extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; final avatar = Container( width: 34, height: 34, @@ -147,8 +148,20 @@ class _ProfileAvatar extends StatelessWidget { border: Border.all( color: Colors.white, ), - image: DecorationImage(image: image, fit: BoxFit.cover), + image: image is AssetImage + ? null + : DecorationImage(image: image, fit: BoxFit.cover), + color: image is AssetImage + ? (isDark ? const Color(0xFF424242) : const Color(0xFFE0E0E0)) + : null, ), + child: image is AssetImage + ? Icon( + Icons.person, + size: 18, + color: isDark ? const Color(0xFF9E9E9E) : const Color(0xFF757575), + ) + : null, ); return GestureDetector( onTap: onTap, diff --git a/lib/src/core/design_system/theme/app_theme.dart b/lib/src/core/design_system/theme/app_theme.dart index 49000f8d..bc3ad4b3 100644 --- a/lib/src/core/design_system/theme/app_theme.dart +++ b/lib/src/core/design_system/theme/app_theme.dart @@ -93,6 +93,9 @@ class AppTheme { fontFamily: 'SN Pro', brightness: Brightness.dark, colorScheme: AppColorScheme.dark, + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + focusColor: Colors.transparent, scaffoldBackgroundColor: AppColors.greyBlack, appBarTheme: const AppBarTheme( elevation: 0, diff --git a/lib/src/core/design_system/tokens/shapes.dart b/lib/src/core/design_system/tokens/shapes.dart index 7fbadcc3..e0511457 100644 --- a/lib/src/core/design_system/tokens/shapes.dart +++ b/lib/src/core/design_system/tokens/shapes.dart @@ -2,7 +2,7 @@ class AppShapes { AppShapes._(); // Squircle/Superellipse defaults used across media surfaces - static const double squircleRadius = 20; + static const double squircleRadius = 15; static const double squircleBorderWidth = 2; static const int squircleBorderAlpha = 70; // ~30% opacity } diff --git a/lib/src/core/di/service_locator.dart b/lib/src/core/di/service_locator.dart index 7052bcca..ddd1569e 100644 --- a/lib/src/core/di/service_locator.dart +++ b/lib/src/core/di/service_locator.dart @@ -5,6 +5,8 @@ import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repositor import 'package:sparksocial/src/core/network/atproto/atproto.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; +import 'package:sparksocial/src/core/network/atproto/data/repositories/pref_repository.dart'; +import 'package:sparksocial/src/core/network/atproto/data/repositories/pref_repository_impl.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository_impl.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/story_repository_impl.dart'; import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; @@ -13,9 +15,6 @@ import 'package:sparksocial/src/core/network/xrpc/service_auth_helper.dart'; import 'package:sparksocial/src/core/pro_video_editor/pro_video_editor_repository.dart'; import 'package:sparksocial/src/core/pro_video_editor/pro_video_editor_repository_impl.dart'; import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; -import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; -import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; -import 'package:sparksocial/src/core/storage/preferences/settings_repository_impl.dart'; import 'package:sparksocial/src/core/storage/storage.dart'; import 'package:sparksocial/src/core/ui/theme/data/repositories/theme_repository.dart'; import 'package:sparksocial/src/core/ui/theme/data/repositories/theme_repository_impl.dart'; @@ -39,10 +38,6 @@ Future initServiceLocator() async { final storageManager = StorageManager.instance; await storageManager.init(); - final sqlCache = SQLCacheImpl(); - await sqlCache.database; - sl.registerSingleton(sqlCache); - final downloadManager = DownloadManagerImpl(); sl.registerSingleton(downloadManager); @@ -62,6 +57,9 @@ Future initServiceLocator() async { // Register SprkRepository with its interface sl.registerSingleton(SprkRepositoryImpl(sl())); + // Register PrefRepository + sl.registerSingleton(PrefRepositoryImpl(sl())); + // Register identity repository sl.registerSingleton(IdentityRepositoryImpl(sl())); @@ -77,8 +75,6 @@ Future initServiceLocator() async { // Register StoryRepository sl.registerSingleton(StoryRepositoryImpl(sl.get())); - // Register SettingsRepository - sl.registerSingleton(SettingsRepositoryImpl()); await downloadManager.init(); // Register OnboardingRepository diff --git a/lib/src/core/feed_algorithms/hardcoded_feed_algorithm.dart b/lib/src/core/feed_algorithms/hardcoded_feed_algorithm.dart deleted file mode 100644 index 223ec31a..00000000 --- a/lib/src/core/feed_algorithms/hardcoded_feed_algorithm.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:atproto/core.dart'; -import 'package:atproto_core/atproto_core.dart'; -import 'package:get_it/get_it.dart'; -import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; -import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; - -typedef FeedViewFunction = Future Function({int? limit, String? cursor}); -typedef ExtraInfoFunction = Future> Function(List uris); - -class HardCodedFeedAlgorithm { - // Map of feed URIs for feeds that use getFeed() - static final Map _feedUris = { - HardCodedFeedEnum.latest: AtUri.parse('at://did:plc:cveom2iroj3mt747sd4qqnr2/so.sprk.feed.generator/latest'), - HardCodedFeedEnum.forYou: AtUri.parse('at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids'), - // mutuals and shared don't have URIs yet (return empty feeds) - }; - - static FeedViewFunction feedViewFromEnum(HardCodedFeedEnum feed) { - return ({int? limit, String? cursor}) async { - limit ??= 10; - final sprkRepository = GetIt.instance(); - final feedRepository = sprkRepository.feed; - - // Timeline is a special case - if (feed == HardCodedFeedEnum.timeline) { - return feedRepository.getTimeline(limit: limit, cursor: cursor); - } - - // Check if this feed has a URI - final feedUri = _feedUris[feed]; - if (feedUri != null) { - return feedRepository.getFeedView(feedUri, limit: limit, cursor: cursor); - } - - // Feeds without URIs return empty feed - return const FeedView(feed: []); - }; - } - - static ExtraInfoFunction? extraInfoFromEnum(HardCodedFeedEnum feed) { - switch (feed) { - case HardCodedFeedEnum.timeline: - case HardCodedFeedEnum.forYou: - case HardCodedFeedEnum.latest: - return null; - } - } -} 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 204f7fb0..7ebcd767 100644 --- a/lib/src/core/network/atproto/data/models/actor_models.dart +++ b/lib/src/core/network/atproto/data/models/actor_models.dart @@ -127,10 +127,3 @@ class ProfileViewDetailed with _$ProfileViewDetailed { factory ProfileViewDetailed.fromJson(Map json) => _$ProfileViewDetailedFromJson(json); } - -@freezed -class UserPreferences with _$UserPreferences { - const factory UserPreferences({required String followMode}) = _UserPreferences; - - factory UserPreferences.fromJson(Map json) => _$UserPreferencesFromJson(json); -} diff --git a/lib/src/core/network/atproto/data/models/feed_models.dart b/lib/src/core/network/atproto/data/models/feed_models.dart index 9c95d70b..dfe10489 100644 --- a/lib/src/core/network/atproto/data/models/feed_models.dart +++ b/lib/src/core/network/atproto/data/models/feed_models.dart @@ -11,77 +11,52 @@ part 'feed_models.freezed.dart'; part 'feed_models.g.dart'; /// https://pub.dev/packages/freezed#union-types <= read this to know how to use pattern matching to know the type of the object +@freezed +class GeneratorViewerState with _$GeneratorViewerState { + @JsonSerializable(explicitToJson: true) + const factory GeneratorViewerState({ + @AtUriConverter() AtUri? like, + }) = _GeneratorViewerState; + const GeneratorViewerState._(); -enum HardCodedFeedEnum { - timeline('Following'), // posts from people you follow (bsky/sprk) - forYou('For You'), // hardcoded algorithm for trending posts (bsky/sprk). for now, it's just the TheVids feed (bsky) - latest('Latest'); // latest sprk posts (sprk) - - const HardCodedFeedEnum(this.name); - final String name; + factory GeneratorViewerState.fromJson(Map json) => _$GeneratorViewerStateFromJson(json); } -/// This model will be used in: -/// - Creating a custom feed -/// - Editing a custom feed -/// -/// The CustomFeedCreatorPage will create a CustomFeed and send it to the API. -/// -/// The CustomFeedPage will need a CustomFeed and will display it. -/// -/// The CustomFeedEditorPage will edit a CustomFeed and send the changes to the API. -/// -/// TODO: make this the same as the lexicon (not implemented yet) @freezed -class CustomFeed with _$CustomFeed { +class GeneratorView with _$GeneratorView { @JsonSerializable(explicitToJson: true) - const factory CustomFeed({ - required ProfileViewBasic? creator, - @Default('Custom Feed') String name, - @Default('Your custom feed') String description, + const factory GeneratorView({ + @AtUriConverter() required AtUri uri, + required String cid, + required String did, + required ProfileViewBasic creator, + required String displayName, + required DateTime indexedAt, + String? description, @Default([]) List descriptionFacets, - @Default([]) List