🤝 26 - 社交App

阶段五:实战项目 · 第26课
✅ 验证通过

📖 核心概念

社交App是Flutter能力的集大成者。Feed流需要高效列表渲染和下拉刷新,关注系统需要实时状态同步,通知推送需要后台服务,图片分享需要选择/压缩/上传。本课将整合前面所有知识,构建一个功能丰富的社交App。

👤 用户与社交关系模型

1. 数据模型设计

// lib/models/user_profile.dart
class UserProfile {
  final String uid;
  final String username;
  final String displayName;
  final String? avatarUrl;
  final String? bio;
  final int followersCount;
  final int followingCount;
  final int postsCount;
  final bool isVerified;
  final DateTime joinedAt;

  const UserProfile({
    required this.uid, required this.username, required this.displayName,
    this.avatarUrl, this.bio,
    this.followersCount = 0, this.followingCount = 0, this.postsCount = 0,
    this.isVerified = false, required this.joinedAt,
  });
}

// 关注关系
class FollowRelation {
  final String followerId;   // 关注者
  final String followingId;  // 被关注者
  final DateTime followedAt;

  const FollowRelation({
    required this.followerId, required this.followingId,
    required this.followedAt,
  });
}

// 动态(帖子)
class Post {
  final String id;
  final String authorId;
  final String authorName;
  final String? authorAvatar;
  final String content;
  final List<String> imageUrls;
  final int likeCount;
  final int commentCount;
  final int shareCount;
  final bool isLiked;
  final DateTime createdAt;

  const Post({
    required this.id, required this.authorId, required this.authorName,
    this.authorAvatar, required this.content,
    this.imageUrls = const [],
    this.likeCount = 0, this.commentCount = 0, this.shareCount = 0,
    this.isLiked = false, required this.createdAt,
  });

  Post copyWith({
    int? likeCount, int? commentCount, int? shareCount, bool? isLiked,
  }) => Post(
    id: id, authorId: authorId, authorName: authorName,
    authorAvatar: authorAvatar, content: content, imageUrls: imageUrls,
    likeCount: likeCount ?? this.likeCount,
    commentCount: commentCount ?? this.commentCount,
    shareCount: shareCount ?? this.shareCount,
    isLiked: isLiked ?? this.isLiked,
    createdAt: createdAt,
  );
}

// 评论
class Comment {
  final String id;
  final String postId;
  final String authorId;
  final String authorName;
  final String? authorAvatar;
  final String content;
  final String? replyToId;     // 回复某条评论
  final DateTime createdAt;

  const Comment({
    required this.id, required this.postId, required this.authorId,
    required this.authorName, this.authorAvatar, required this.content,
    this.replyToId, required this.createdAt,
  });
}

// 通知
class AppNotification {
  final String id;
  final NotificationType type;
  final String fromUserId;
  final String fromUserName;
  final String? fromUserAvatar;
  final String? referenceId;    // 关联的帖子/评论ID
  final String message;
  final bool isRead;
  final DateTime createdAt;

  const AppNotification({
    required this.id, required this.type, required this.fromUserId,
    required this.fromUserName, this.fromUserAvatar, this.referenceId,
    required this.message, this.isRead = false, required this.createdAt,
  });
}

enum NotificationType { like, comment, follow, mention, system }

📰 Feed流实现

2. Feed Provider与无限滚动

// lib/providers/feed_provider.dart
class FeedProvider extends ChangeNotifier {
  final PostRepository _repo;
  List<Post> _posts = [];
  String? _nextCursor;     // 游标分页
  bool _isLoading = false;
  bool _hasMore = true;

  List<Post> get posts => _posts;
  bool get isLoading => _isLoading;
  bool get hasMore => _hasMore;

  FeedProvider(this._repo);

  // 首次加载/下拉刷新
  Future<void> refresh() async {
    _nextCursor = null;
    _hasMore = true;
    _isLoading = true;
    notifyListeners();

    final result = await _repo.getFeed(cursor: null, limit: 20);
    _posts = result.items;
    _nextCursor = result.nextCursor;
    _hasMore = result.hasMore;
    _isLoading = false;
    notifyListeners();
  }

  // 加载更多
  Future<void> loadMore() async {
    if (_isLoading || !_hasMore) return;
    _isLoading = true;
    notifyListeners();

    final result = await _repo.getFeed(cursor: _nextCursor, limit: 20);
    _posts.addAll(result.items);
    _nextCursor = result.nextCursor;
    _hasMore = result.hasMore;
    _isLoading = false;
    notifyListeners();
  }

  // 点赞/取消点赞(乐观更新)
  Future<void> toggleLike(String postId) async {
    final index = _posts.indexWhere((p) => p.id == postId);
    if (index == -1) return;

    final post = _posts[index];
    final wasLiked = post.isLiked;

    // 乐观更新UI
    _posts[index] = post.copyWith(
      isLiked: !wasLiked,
      likeCount: post.likeCount + (wasLiked ? -1 : 1),
    );
    notifyListeners();

    // 网络请求
    try {
      if (wasLiked) {
        await _repo.unlikePost(postId);
      } else {
        await _repo.likePost(postId);
      }
    } catch (e) {
      // 回滚
      _posts[index] = post;
      notifyListeners();
    }
  }

  // 发布新动态
  Future<void> createPost(String content, {List<String>? imagePaths}) async {
    final post = await _repo.createPost(content, imagePaths: imagePaths);
    _posts.insert(0, post);
    notifyListeners();
  }
}

3. Feed页面UI

// lib/pages/feed_page.dart
class FeedPage extends StatefulWidget {
  @override
  State<FeedPage> createState() => _FeedPageState();
}

class _FeedPageState extends State<FeedPage> {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    context.read<FeedProvider>().refresh();
    _scrollController.addListener(_onScroll);
  }

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 300) {
      context.read<FeedProvider>().loadMore();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Consumer<FeedProvider>(
        builder: (context, feed, _) {
          return RefreshIndicator(
            onRefresh: () => feed.refresh(),
            child: ListView.builder(
              controller: _scrollController,
              itemCount: feed.posts.length + (feed.hasMore ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == feed.posts.length) {
                  return const Padding(
                    padding: EdgeInsets.all(16),
                    child: Center(child: CircularProgressIndicator()),
                  );
                }
                return PostCard(
                  post: feed.posts[index],
                  onLike: () => feed.toggleLike(feed.posts[index].id),
                  onComment: () => _navigateToComments(feed.posts[index]),
                  onShare: () => _sharePost(feed.posts[index]),
                  onProfile: () => _navigateToProfile(feed.posts[index].authorId),
                );
              },
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showCreatePostSheet,
        child: const Icon(Icons.edit),
      ),
    );
  }
}

❤️ 关注系统

4. 关注状态管理与按钮

// lib/providers/follow_provider.dart
class FollowProvider extends ChangeNotifier {
  final FollowRepository _repo;
  final Set<String> _followingIds = {}; // 已关注的用户ID集合
  bool _isLoading = false;

  bool isFollowing(String userId) => _followingIds.contains(userId);

  Future<void> loadFollowing() async {
    _isLoading = true;
    notifyListeners();
    final ids = await _repo.getFollowingIds();
    _followingIds.clear();
    _followingIds.addAll(ids);
    _isLoading = false;
    notifyListeners();
  }

  Future<void> toggleFollow(String userId) async {
    final wasFollowing = _followingIds.contains(userId);

    // 乐观更新
    if (wasFollowing) {
      _followingIds.remove(userId);
    } else {
      _followingIds.add(userId);
    }
    notifyListeners();

    try {
      if (wasFollowing) {
        await _repo.unfollow(userId);
      } else {
        await _repo.follow(userId);
      }
    } catch (e) {
      // 回滚
      if (wasFollowing) {
        _followingIds.add(userId);
      } else {
        _followingIds.remove(userId);
      }
      notifyListeners();
    }
  }
}

// 关注按钮组件
class FollowButton extends StatelessWidget {
  final String userId;

  const FollowButton({required this.userId});

  @override
  Widget build(BuildContext context) {
    final isFollowing = context.select<FollowProvider, bool>(
      (p) => p.isFollowing(userId),
    );

    return AnimatedContainer(
      duration: const Duration(milliseconds: 200),
      child: isFollowing
        ? OutlinedButton(
            onPressed: () => context.read<FollowProvider>().toggleFollow(userId),
            child: const Text('已关注'),
          )
        : FilledButton(
            onPressed: () => context.read<FollowProvider>().toggleFollow(userId),
            child: const Text('关注'),
          ),
    );
  }
}

🔔 通知系统

5. 通知推送与展示

// lib/providers/notification_provider.dart
class NotificationProvider extends ChangeNotifier {
  final NotificationRepository _repo;
  List<AppNotification> _notifications = [];
  int _unreadCount = 0;

  int get unreadCount => _unreadCount;
  List<AppNotification> get notifications => _notifications;

  // WebSocket推送更新
  void onNewNotification(AppNotification notification) {
    _notifications.insert(0, notification);
    _unreadCount++;
    notifyListeners();

    // 显示本地通知
    _showLocalNotification(notification);
  }

  Future<void> _showLocalNotification(AppNotification n) async {
    final plugin = FlutterLocalNotificationsPlugin();
    await plugin.show(
      n.id.hashCode,
      n.fromUserName,
      n.message,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'social_channel', '社交通知',
          importance: Importance.high,
          priority: Priority.high,
        ),
        iOS: DarwinNotificationDetails(),
      ),
    );
  }

  // 标记已读
  Future<void> markAsRead(String notificationId) async {
    final index = _notifications.indexWhere((n) => n.id == notificationId);
    if (index != -1) {
      _notifications[index] = AppNotification(
        id: n.id, type: n.type, fromUserId: n.fromUserId,
        fromUserName: n.fromUserName, fromUserAvatar: n.fromUserAvatar,
        referenceId: n.referenceId, message: n.message,
        isRead: true, createdAt: n.createdAt,
      );
      _unreadCount = _notifications.where((n) => !n.isRead).length;
      notifyListeners();
    }
    await _repo.markAsRead(notificationId);
  }
}

// 通知徽章
class NotificationBadge extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final count = context.select<NotificationProvider, int>(
      (p) => p.unreadCount,
    );
    return Stack(
      children: [
        const Icon(Icons.notifications),
        if (count > 0)
          Positioned(right: 0, top: 0,
            child: Container(
              padding: const EdgeInsets.all(2),
              decoration: const BoxDecoration(
                color: Colors.red, shape: BoxShape.circle),
              constraints: const BoxConstraints(minWidth: 14, minHeight: 14),
              child: Text('$count', textAlign: TextAlign.center,
                style: const TextStyle(fontSize: 9, color: Colors.white)),
            )),
      ],
    );
  }
}

6. 图片选择与压缩

// lib/services/image_service.dart
class ImageService {
  final ImagePicker _picker = ImagePicker();

  Future<List<File>> pickImages({int maxCount = 9}) async {
    final images = await _picker.pickMultiImage(imageQuality: 80);
    if (images.length > maxCount) {
      throw '最多选择$maxCount张图片';
    }
    return images.map((x) => File(x.path)).toList();
  }

  Future<File> takePhoto() async {
    final photo = await _picker.pickImage(
      source: ImageSource.camera,
      maxWidth: 1920,
      imageQuality: 80,
    );
    if (photo == null) throw '取消拍照';
    return File(photo.path);
  }

  // 压缩图片
  Future<File> compressImage(File file) async {
    final tempDir = await getTemporaryDirectory();
    final targetPath = '${tempDir.path}/${DateTime.now().millisecondsSinceEpoch}.jpg';

    final result = await FlutterImageCompress.compressAndGetFile(
      file.absolute.path, targetPath,
      quality: 75,
      minWidth: 1080,
      rotate: 0,
    );
    return File(result!.path);
  }
}
✓ Feed流:下拉刷新+无限滚动+游标分页 ✓ 点赞动画:心形缩放+粒子效果+乐观更新 ✓ 关注系统:即时关注/取关+UI状态同步 ✓ 通知推送:本地通知+未读徽章+WebSocket实时 ✓ 图片分享:选择/拍照/压缩/多图上传 ✓ 个人主页:头像/简介/动态列表/关注按钮 ✓ 发布动态:文字+图片+话题标签

📝 练习

  1. 实现动态的评论楼层——支持回复评论、展示评论树结构
  2. 添加短视频功能——录制/上传15秒短视频,自动播放
  3. 实现智能推荐——基于关注关系和兴趣标签的Feed推荐算法
  4. 添加私信功能——一对一加密聊天+消息已读

🏆 成就解锁:社交达人

你已构建了一个社交App!综合运用了:

下一课预告:Hero动画与页面转场——让你的App拥有丝滑的共享元素过渡效果!