Привет! Продолжаю выкладывать перевод статьи, которую я использовал как основу для реализации социального функционала в нашем проекте Dom24x7, где люди могут общаться друг с другом, решать возникающие бытовые проблемы, а также взаимодействовать с УК/ТСЖ. Первую часть статьи можно прочитать тут, а вторую смотрите тут.

Итак...

Создаем иконку угасания

Пришло время для еще одного глобального компонента. Это довольно простой виджет, который отвечает за угасание иконки, или насыщение ее цвета, путем нажатия на нее.

Создайте components/app_widgets/tap_fade_icon.dart и добавьте следующее:

import 'package:flutter/material.dart';

/// {@template tap_fade_icon}
/// A tappable icon that fades colors when tapped and held.
/// {@endtemplate}
class TapFadeIcon extends StatefulWidget {
  /// {@macro tap_fade_icon}
  const TapFadeIcon({
    Key? key,
    required this.onTap,
    required this.icon,
    required this.iconColor,
    this.size = 22,
  }) : super(key: key);

  /// Callback to handle tap.
  final VoidCallback onTap;

  /// Color of the icon.
  final Color iconColor;

  /// Type of icon.
  final IconData icon;

  /// Icon size.
  final double size;

  @override
  _TapFadeIconState createState() => _TapFadeIconState();
}

class _TapFadeIconState extends State<TapFadeIcon> {
  late Color color = widget.iconColor;

  void handleTapDown(TapDownDetails _) {
    setState(() {
      color = widget.iconColor.withOpacity(0.7);
    });
  }

  void handleTapUp(TapUpDetails _) {
    setState(() {
      color = widget.iconColor;
    });

    widget.onTap(); // Execute callback.
  }

  @override
  void didUpdateWidget(covariant TapFadeIcon oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.iconColor != widget.iconColor) {
      color = widget.iconColor;
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: handleTapDown,
      onTapUp: handleTapUp,
      child: Icon(
        widget.icon,
        color: color,
        size: widget.size,
      ),
    );
  }
}

Вы можете поподробнее остановиться на изучении этого виджета самостоятельно.

Добавьте этот класс в нужный баррель-файл. Откройте components/app_widgets/app_widgets.dart и добавьте нижележащий код:

export 'avatars.dart';
export 'tap_fade_icon.dart'; // ADD THIS

Создаем пользовательскую ленту

Как уже было упомянуто ранее, наш инстаграм клон можно теоретически разбить на две части: на пользовательскую (user) и хронологическую (timeline) ленты. Каждый пользователь имеет свою уникальную пользовательскую ленту, где отображаются все посты, сделанные им; в то время как хронологическая лента это комбинация всех лент пользователей, на которых вы подписаны.

В этой секции, вы создадите функционал, позволяющий продвигать действия (в данном случае - посты пользователя) прямиком в пользовательскую ленту.

Для того чтобы сделать наш клон Instagram максимально приближенным по ощущениям во время использования и интерактивности к настоящему приложению, мы в первую очередь должны закончить с кодом для пользовательской ленты. Для этого нам нужно:

  • создать экран, где можно добавлять новые посты;

  • добавить PictureViewer (hero анимации);

  • обновить AppBar (верхнюю панель приложения) так, чтобы мы могли добавить больше чем одну фотографию в профиль;

  • внедрить возможность подписываться и отписываться от лент других пользователей.

Разобравшись со всем этим, далее, мы сможем перейти к созданию хронологической ленты.

Создаем экран «Новая публикация»

Это тот самый экран, где вы добавляете новое фото в свой профиль вместе с подписью к нему.

Создайте файл components/new_post/new_post_screen.dart и добавьте в него:

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';
import 'package:transparent_image/transparent_image.dart';

import '../../app/app.dart';
import '../app_widgets/app_widgets.dart';

/// Screen to choose photos and add a new feed post.
class NewPostScreen extends StatefulWidget {
  /// Create a [NewPostScreen].
  const NewPostScreen({Key? key}) : super(key: key);

  /// Material route to this screen.
  static Route get route =>
      MaterialPageRoute(builder: (_) => const NewPostScreen());

  @override
  _NewPostScreenState createState() => _NewPostScreenState();
}

class _NewPostScreenState extends State<NewPostScreen> {
  static const double maxImageHeight = 1000;
  static const double maxImageWidth = 800;

  final _formKey = GlobalKey<FormState>();
  final _text = TextEditingController();

  XFile? _pickedFile;
  bool loading = false;

  final picker = ImagePicker();

  Future<void> _pickFile() async {
    _pickedFile = await picker.pickImage(
      source: ImageSource.gallery,
      maxHeight: maxImageHeight,
      maxWidth: maxImageWidth,
      imageQuality: 70,
    );
    setState(() {});
  }

  Future<void> _postImage() async {
    if (_pickedFile == null) {
      context.removeAndShowSnackbar('Please select an image first');
      return;
    }

    if (!_formKey.currentState!.validate()) {
      context.removeAndShowSnackbar('Please enter a caption');
      return;
    }
    _setLoading(true);

    final client = context.appState.client;

    var decodedImage =
        await decodeImageFromList(await _pickedFile!.readAsBytes());

    final imageUrl =
        await client.images.upload(AttachmentFile(path: _pickedFile!.path));

    if (imageUrl != null) {
      final _resizedUrl = await client.images.getResized(
        imageUrl,
        const Resize(300, 300),
      );

      if (_resizedUrl != null && client.currentUser != null) {
        await FeedProvider.of(context).bloc.onAddActivity(
          feedGroup: 'user',
          verb: 'post',
          object: 'image',
          data: {
            'description': _text.text,
            'image_url': imageUrl,
            'resized_image_url': _resizedUrl,
            'image_width': decodedImage.width,
            'image_height': decodedImage.height,
            'aspect_ratio': decodedImage.width / decodedImage.height
          },
        );
      }
    }

    _setLoading(false, shouldCallSetState: false);
    context.removeAndShowSnackbar('Post created!');

    Navigator.of(context).pop();
  }

  void _setLoading(bool state, {bool shouldCallSetState = true}) {
    if (loading != state) {
      loading = state;
      if (shouldCallSetState) {
        setState(() {});
      }
    }
  }

  @override
  void dispose() {
    _text.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        leading: TapFadeIcon(
          onTap: () => Navigator.pop(context),
          icon: Icons.close,
          iconColor: Theme.of(context).appBarTheme.iconTheme!.color!,
        ),
        actions: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Center(
              child: GestureDetector(
                onTap: _postImage,
                child: const Text('Share', style: AppTextStyle.textStyleAction),
              ),
            ),
          )
        ],
      ),
      body: loading
          ? Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  CircularProgressIndicator(),
                  SizedBox(height: 12),
                  Text('Uploading...')
                ],
              ),
            )
          : ListView(
              children: [
                InkWell(
                  onTap: _pickFile,
                  child: SizedBox(
                    height: 400,
                    child: (_pickedFile != null)
                        ? FadeInImage(
                            fit: BoxFit.contain,
                            placeholder: MemoryImage(kTransparentImage),
                            image: Image.file(File(_pickedFile!.path)).image,
                          )
                        : Container(
                            decoration: const BoxDecoration(
                              gradient: LinearGradient(
                                  begin: Alignment.bottomLeft,
                                  end: Alignment.topRight,
                                  colors: [
                                    AppColors.bottomGradient,
                                    AppColors.topGradient
                                  ]),
                            ),
                            height: 300,
                            child: const Center(
                              child: Text(
                                'Tap to select an image',
                                style: TextStyle(
                                  color: AppColors.light,
                                  fontSize: 18,
                                  shadows: <Shadow>[
                                    Shadow(
                                      offset: Offset(2.0, 1.0),
                                      blurRadius: 3.0,
                                      color: Colors.black54,
                                    ),
                                    Shadow(
                                      offset: Offset(1.0, 1.5),
                                      blurRadius: 5.0,
                                      color: Colors.black54,
                                    ),
                                  ],
                                ),
                              ),
                            ),
                          ),
                  ),
                ),
                const SizedBox(
                  height: 22,
                ),
                Form(
                  key: _formKey,
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: TextFormField(
                      controller: _text,
                      decoration: const InputDecoration(
                        hintText: 'Write a caption',
                        border: InputBorder.none,
                      ),
                      validator: (text) {
                        if (text == null || text.isEmpty) {
                          return 'Caption is empty';
                        }
                        return null;
                      },
                    ),
                  ),
                ),
              ],
            ),
    );
  }
}

Давайте разберем данный код в деталях:

  • по тому же принципу как и ранее, здесь, мы используем пакет image_picker для того, чтобы выбрать изображение. Как только оно выбрано, мы задаем значение _pickedFile для локальной переменной;

  • в этой секции мы также создаем TextFormField, который требует от нас ввода описания для для нашего поста;

  • как только изображение было выбрано и описание введено, пользователь может нажать кнопку Share, чтобы выложить свой пост. Если эти условия не были соблюдено, то на экране появится ошибка.

Кнопка Share вызывает метод _postImage, который выполняет следующее:

  1. Устанавливает статус true для состояния загрузки изображения;

  2. Декодирует изображение так,  чтобы оно приняло указанный нами размер;

  3. Загружает изображение на Stream CDN;

  4. При помощи метода getResized создает уменьшенную версию изображения;

  5. Использует FeedProvider.of(context).bloc, чтобы возвратить FeedBloc и создает новый пост при помощи onAddActivity.

В случае с нашим инстаграм клоном, создание одного поста требует наличия данных об: исполнителе (actor), действии (verb) и объекте (object). Исполнитель - это субъект, выполняющий действие (пользователь). Действие в нашем случае - публикует. Объект или то над чем совершается действие - изображение.

Проще говоря, текущий пользователь публикует изображение (исполнитель, действие, объект) в пользовательскую ленту. Затем, мы указываем немного дополнительной информации, включающей:

  • описание изображения;

  • соотношение сторон (будет разобрано позже);

  • URL адрес самого изображения и его уменьшенной версии.

  • Вот и все. Статья была большой и, надеюсь, полезной.

Больше информации про то, как добавлять действия в ленту вы можете найти в соответствующей документации. Действия и ленты бывают довольно сложными. Но благодаря этой сложности мы можем работать с более продвинутыми функциями. Однако пока что, описанного выше будет вполне достаточно.

Ваш экран сейчас должен выглядеть следующим образом:

Создайте баррель-файл components/new_post/new_post.dart и добавьте:

export 'new_post_screen.dart';

Добавляем навигацию и отображение постов (действий) в профиле

Откройте файл components/profile/profile_page.dart.

Измените виджет _NoPostsMessage, чтобы иметь возможность переходить к NewPostScreen (экрану «Новая публикация»).

...

import '../new_post/new_post.dart';

...

class _NoPostsMessage extends StatelessWidget {
  const _NoPostsMessage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Text('This is too empty'),
        const SizedBox(height: 12),
        ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(NewPostScreen.route); // ADD THIS
          },
          child: const Text('Add a post'),
        )
      ],
    );
  }
}

...

В ProfilePage приведите виджет feedBuilder к следующему виду:

import 'package:cached_network_image/cached_network_image.dart';

...

feedBuilder: (context, activities) {
  return RefreshIndicator(
    onRefresh: () async {
      await FeedProvider.of(context)
          .bloc
          .currentUser!
          .get(withFollowCounts: true);
      return FeedProvider.of(context)
          .bloc
          .queryEnrichedActivities(feedGroup: 'user');
    },
    child: CustomScrollView(
      slivers: [
        SliverToBoxAdapter(
          child: _ProfileHeader(
            numberOfPosts: activities.length,
          ),
        ),
        const SliverToBoxAdapter(
          child: _EditProfileButton(),
        ),
        const SliverToBoxAdapter(
          child: SizedBox(height: 24),
        ),
        SliverGrid(
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 3,
            crossAxisSpacing: 1,
            mainAxisSpacing: 1,
          ),
          delegate: SliverChildBuilderDelegate(
            (context, index) {
              final activity = activities[index];
              final url =
                        activity.extraData!['resized_image_url'] as String;
              return CachedNetworkImage(
                key: ValueKey('image-${activity.id}'),
                width: 200,
                height: 200,
                fit: BoxFit.cover,
                imageUrl: url,
              );
            },
            childCount: activities.length,
          ),
        )
      ],
    ),
  );
},

...

Теперь, когда в вашем приложении можно добавлять действия (посты), нам нужно решить проблему с тем, как отобразить эти посты в нашей ленте. Билдер, приведенный выше, выполняет следующее:

  1. Создает CustomScrollView;

  2. Отображает  _ProfileHeader, показывающий в поле Публикации (вверху профиля) цифру соответствующую вашему реальному количеству постов в профиле;

  3. Отображает _EditProfileButton (кнопку «Редактировать профиль»);

  4. Обертывает список в RefreshIndicator, позволяя пользователям путем свайпа вниз обновлять страницу профиля с постами через получение последних данных с сервера. Достигается это путем вызова методов currentUser.get и bloc.queryEnrichedActivities. Далее queryEnrichedActivities обновит состояние FeedBloc для данной группы лент.

Готово! Теперь вы должны иметь возможность заходить в приложение под одним из наших пользователей и загружать фотографии в свою пользовательскую ленту (профиль). В видео ниже приведен пример того, как это выглядит:

Добавляем анимации переходов при просмотре изображений (PictureViewer)

Перед тем как мы наконец перейдем к хронологической ленте, давайте узнаем немного про переходы.

Вы наверняка замечали то, как плавно открывается изображение при нажатии на него из решетки изображений в реальном Instagram. Для того, чтобы воссоздать это поведение, мы создадим просмотрщик изображений включающий в себя функцию hero анимаций (переход изображений от низкого разрешения к высокому).  Так мы получим плавную анимацию при открытии изображения на полный экран, предварительно нажав на него из решетки изображений. Также мы добавим функции увеличения изображения и возможность перемещаться по нему, когда мы приближаем его.

Чтобы получить это поведение, мы:

  1. Создаем CustomRectTween для нашей собственной hero анимации;

  2. Создаем PageRoute, который, в свою очередь, создает FadeTransition;

  3. Обновляем UI в файле components/profile/profile_page.dart, чтобы выполнить переход;

  4. Находим применение CachedNetworkImage, чтобы реализовать переход от кэшированного изображения низкого разрешения к его версии с более высоким разрешением;

  5. Используем виджет InteractiveViewer для внедрения возможности приближать изображение двумя пальцами и перемещаться по нему когда оно находится в таком состоянии.

Навигация

Первым делом, вам нужно будет создать несколько классов помощников для реализации переходов.

Создайте файл app/navigation/custom_rect_tween.dart и добавьте следующее:

import 'dart:ui';

import 'package:flutter/widgets.dart';

/// {@template custom_rect_tween}
/// Linear RectTween with a [Curves.easeOut] curve.
///
/// Less dramatic than the regular [RectTween] used in [Hero] animations.
/// {@endtemplate}
class CustomRectTween extends RectTween {
  /// {@macro custom_rect_tween}
  CustomRectTween({
    required Rect? begin,
    required Rect? end,
  }) : super(begin: begin, end: end);

  @override
  Rect? lerp(double t) {
    final elasticCurveValue = Curves.easeOut.transform(t);
    if (begin == null || end == null) return null;
    return Rect.fromLTRB(
      lerpDouble(begin!.left, end!.left, elasticCurveValue)!,
      lerpDouble(begin!.top, end!.top, elasticCurveValue)!,
      lerpDouble(begin!.right, end!.right, elasticCurveValue)!,
      lerpDouble(begin!.bottom, end!.bottom, elasticCurveValue)!,
    );
  }
}

Этот класс расширяет RectTween и перезаписывает метод lerp. Мы вернемся к этому позже, когда будем заменять стандартную hero анимацию. Термин lerp описывает интерполяцию между начальным и конечным значением переменной на промежутке времени (t).

Далее, создайте файл  app/navigation/hero_dialog_route.dart и добавьте следующее:

import 'package:flutter/material.dart';

/// {@template hero_dialog_route}
/// Custom [PageRoute] that creates an overlay dialog (popup effect).
///
/// Best used with a [Hero] animation.
/// {@endtemplate}
class HeroDialogRoute<T> extends PageRoute<T> {
  /// {@macro hero_dialog_route}
  HeroDialogRoute({
    required WidgetBuilder builder,
    RouteSettings? settings,
    bool fullscreenDialog = false,
  })  : _builder = builder,
        super(settings: settings, fullscreenDialog: fullscreenDialog);

  final WidgetBuilder _builder;

  @override
  bool get opaque => false;

  @override
  bool get barrierDismissible => true;

  @override
  Duration get transitionDuration => const Duration(milliseconds: 300);

  @override
  bool get maintainState => true;

  @override
  Color get barrierColor => Colors.black54;

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
    return FadeTransition(opacity: animation, child: child);
  }

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    return _builder(context);
  }

  @override
  String get barrierLabel => 'Hero Dialog Open';
}

В этой части кода мы пользуемся классом PageRoute, который помогает нам реализовать плавный переход (FadeTransition) c задним фоном черного цвета. Есть множество других способов как вы можете сделать такой же переход. Самостоятельно, вы можете подольше задержаться на этом классе, попробовать поиграться с ним и посмотреть какие возможности он предоставляет.  Ранее в статье мы уже использовали PageRouteBuilder, который вы можете использовать в качестве альтернативного способа для создания такого перехода. Однако, благодаря расширению этого класса у вас есть больше элементов контроля.

Создайте баррель-файл app/navigation/navigation.dart  и добавьте в него:

export 'custom_rect_tween.dart';
export 'hero_dialog_route.dart';

Обновите app/app.dart следующим образом:

export 'state/state.dart';
export 'theme.dart';
export 'stream_agram.dart';
export 'utils.dart';
export 'navigation/navigation.dart'; // ADD THIS

UI

Откройте файл components/profile/profile_page.dart и добавьте в него всё приведенное ниже:

...

class _PictureViewer extends StatelessWidget {
  const _PictureViewer({
    Key? key,
    required this.activity,
  }) : super(key: key);

  final EnrichedActivity activity;

  @override
  Widget build(BuildContext context) {
    final resizedUrl = activity.extraData!['resized_image_url'] as String?;
    final fullSizeUrl = activity.extraData!['image_url'] as String;
    final aspectRatio = activity.extraData!['aspect_ratio'] as double?;

    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: Colors.transparent,
      ),
      extendBodyBehindAppBar: true,
      body: InteractiveViewer(
        child: Center(
          child: Hero(
            tag: 'hero-image-${activity.id}',
            createRectTween: (begin, end) {
              return CustomRectTween(begin: begin, end: end);
            },
            child: AspectRatio(
              aspectRatio: aspectRatio ?? 1,
              child: CachedNetworkImage(
                fadeInDuration: Duration.zero,
                placeholder: (resizedUrl != null)
                    ? (context, url) => CachedNetworkImage(
                          imageBuilder: (context, imageProvider) =>
                              DecoratedBox(
                            decoration: BoxDecoration(
                              image: DecorationImage(
                                image: imageProvider,
                                fit: BoxFit.contain,
                              ),
                            ),
                          ),
                          imageUrl: resizedUrl,
                        )
                    : null,
                imageBuilder: (context, imageProvider) => DecoratedBox(
                  decoration: BoxDecoration(
                    image: DecorationImage(
                      image: imageProvider,
                      fit: BoxFit.contain,
                    ),
                  ),
                ),
                imageUrl: fullSizeUrl,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Код выше - немного особенный. Здесь мы делаем следующее:

  • получаем данные о URL адресах обычного изображения и его уменьшенной версии, а также данные о соотношении его сторон;

  • возвращаем кэшированное изображение CachedNetworkImage с URL адресом изображения в полном разрешении, и устанавливаем заглушку placeholder  в качестве текущего CachedNetworkImage в случае для уменьшенного изображение, которое уже было кэшировано;

  • используем виджет AspectRatio, который отвечает за то, чтобы оба изображения (полного разрешения и уменьшенного) занимали одинаковую площадь экрана;

  • для fadeInDuration устанавливаем значение Duration.zero.

  • оборачиваем всё в виджете Hero, используя CustomRectTween для аргумента createRectTween.

  • оборачиваем все в виджете InteractiveViewer для того, чтобы пользователи могли увеличивать и перемещаться по изображению.

Вышеописанный код обеспечивает не только плавный переход от кэшированного изображения маленького разрешения к изображению в максимальном разрешении (если оно уже было загружено), но и одновременно превращает этот переход в hero анимацию! ????

Теперь, зайдем в виджет ProfilePage и приведем код для SliverChildBuilderDelegate к следующему виду:

...

delegate: SliverChildBuilderDelegate(
  (context, index) {
    final activity = activities[index];
    final url =
        activity.extraData!['resized_image_url'] as String;
    return GestureDetector(
      onTap: () {
        Navigator.of(context).push(
          HeroDialogRoute(
            builder: (context) {
              return _PictureViewer(activity: activity);
            },
          ),
        );
      },
      child: Hero(
        tag: 'hero-image-${activity.id}',
        child: CachedNetworkImage(
          key: ValueKey('image-${activity.id}'),
          width: 200,
          height: 200,
          fit: BoxFit.cover,
          imageUrl: url,
        ),
      ),
    );
  },
  childCount: activities.length,
),

...

В вышеприведенном коде мы оборачиваем CashedNewtworkImage в виджете Hero и в виджете GestureDetector, который выполняет переход с помощью недавно созданного  HeroDialogRoute, который в свою в свою очередь открывает виджет _PictureViewer.

⚠️ Убедитесь, что вы везде используете одни и те же Hero тэги, для того, чтобы при переходах не возникало ошибок. Также убедитесь, что вы не дублируете одни и те же тэги на одном экране - причина по которой мы используем activity.id.

Теперь при нажатии на изображение, вы должны увидеть качественную плавную анимацию как на видео ниже:

Изменяем верхнюю панель

На данный момент наша верхняя панель (AppBar) выглядит немного пустой. Вы наверное также заметили, что сейчас нет никакого способа добавить еще один пост в наш профиль, если он уже содержит один.

Давайте изменим это.

Откройте components/home/home_screen.dart и обновите AppBar при помощи аргумента actions:

...

import '../app_widgets/app_widgets.dart';
import '../new_post/new_post.dart';

...

AppBar(

...

	actions: [
    Padding(
      padding: const EdgeInsets.all(8),
      child: TapFadeIcon(
        onTap: () => Navigator.of(context).push(NewPostScreen.route),
        icon: Icons.add_circle_outline,
        iconColor: iconColor,
      ),
    ),
    Padding(
      padding: const EdgeInsets.all(8),
      child: TapFadeIcon(
        onTap: () async {
          context.removeAndShowSnackbar('Not part of the demo');
        },
        icon: Icons.favorite_outline,
        iconColor: iconColor,
      ),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: TapFadeIcon(
        onTap: () => context.removeAndShowSnackbar('Not part of the demo'),
        icon: Icons.call_made,
        iconColor: iconColor,
      ),
    ),
  ],

...

В данном коде мы используем класс TapFadeIcon, который мы создали ранее, для того, чтобы создать иконки на нашей верхней панели. Две из этих иконок используются лишь в декоративных целях и не несут никакого функционала. Однако самая первая иконка открывает класс NewPostScreen (Экран - «Новая публикация») при нажатии на нее.

Теперь вы должны иметь возможность добавлять множество постов в ваш профиль, а ваша верхняя панель должна выглядеть так:

Подписки и подписчики

Возможность подписываться на и отписываться от других пользователей является ключевой особенностью Instagram. Без нее, мы бы ставили лайки только самим себе. ????

Создаем вкладку поиска

Создайте файл components/search/search_page.dart и добавьте в него следующий код:

import 'package:flutter/material.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import '../../app/app.dart';
import '../app_widgets/app_widgets.dart';

/// Page to find other users and follow/unfollow.
class SearchPage extends StatelessWidget {
  /// Create a new [SearchPage].
  const SearchPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final users = List<DemoAppUser>.from(DemoAppUser.values)
      ..removeWhere((it) => it.id == context.appState.user.id);
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, index) {
        return _UserProfile(userId: users[index].id!);
      },
    );
  }
}

class _UserProfile extends StatefulWidget {
  const _UserProfile({
    Key? key,
    required this.userId,
  }) : super(key: key);

  final String userId;

  @override
  __UserProfileState createState() => __UserProfileState();
}

class __UserProfileState extends State<_UserProfile> {
  late StreamUser streamUser;
  late bool isFollowing;
  late Future<StreamagramUser> userDataFuture = getUser();

  Future<StreamagramUser> getUser() async {
    final userClient = context.appState.client.user(widget.userId);
    final futures = await Future.wait([
      userClient.get(),
      _isFollowingUser(widget.userId),
    ]);
    streamUser = futures[0] as StreamUser;
    isFollowing = futures[1] as bool;

    return StreamagramUser.fromMap(streamUser.data!);
  }

  /// Determine if the current authenticated user is following [user].
  Future<bool> _isFollowingUser(String userId) async {
    return FeedProvider.of(context).bloc.isFollowingFeed(followerId: userId);
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<StreamagramUser>(
      future: userDataFuture,
      builder: (context, snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.waiting:
            return const SizedBox.shrink();
          default:
            if (snapshot.hasError) {
              return const Padding(
                padding: EdgeInsets.all(8.0),
                child: Text('Could not load profile'),
              );
            } else {
              final userData = snapshot.data;
              if (userData != null) {
                return _ProfileTile(
                  user: streamUser,
                  userData: userData,
                  isFollowing: isFollowing,
                );
              }
              return const SizedBox.shrink();
            }
        }
      },
    );
  }
}

class _ProfileTile extends StatefulWidget {
  const _ProfileTile({
    Key? key,
    required this.user,
    required this.userData,
    required this.isFollowing,
  }) : super(key: key);

  final StreamUser user;
  final StreamagramUser userData;
  final bool isFollowing;

  @override
  __ProfileTileState createState() => __ProfileTileState();
}

class __ProfileTileState extends State<_ProfileTile> {
  bool _isLoading = false;
  late bool _isFollowing = widget.isFollowing;

  Future<void> followOrUnfollowUser(BuildContext context) async {
    setState(() {
      _isLoading = true;
    });
    if (_isFollowing) {
      final bloc = FeedProvider.of(context).bloc;
      await bloc.unfollowFeed(unfolloweeId: widget.user.id);
      _isFollowing = false;
    } else {
      await FeedProvider.of(context)
          .bloc
          .followFeed(followeeId: widget.user.id);
      _isFollowing = true;
    }
    FeedProvider.of(context)
        .bloc
        .queryEnrichedActivities(
          feedGroup: 'timeline',
          flags: EnrichmentFlags()
            ..withOwnReactions()
            ..withRecentReactions()
            ..withReactionCounts(),
        );

    setState(() {
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Avatar.medium(streamagramUser: widget.userData),
        ),
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(widget.user.id, style: AppTextStyle.textStyleBold),
              Text(
                widget.userData.fullName,
                style: AppTextStyle.textStyleFaded,
              ),
            ],
          ),
        ),
        const Spacer(),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 8.0),
          child: _isLoading
              ? const CircularProgressIndicator(strokeWidth: 3)
              : OutlinedButton(
                  onPressed: () {
                    followOrUnfollowUser(context);
                  },
                  child: _isFollowing
                      ? const Text('Unfollow')
                      : const Text('Follow'),
                ),
        )
      ],
    );
  }
}

Здесь довольно немало информации и возможно вам лучше изучить эту часть самостоятельно в вашем собственном темпе. Если коротко, в данном коде мы:

  • получаем всех наших фейковых пользователей и убираем из этого списка одного, под которым мы зашли в приложение;

  • отображаем всех пользователей, используя _UserProfile;

  • вызываем метод getUser в виджете _UserProfile. При помощи этого метода мы получаем последнюю информацию с сервера о пользователе и проверяем не подписан ли уже этот пользователь на другого при помощи метода isFollowingFeed в FeedBloc;

  • используем виджет FutureBuilder для того, чтобы дождаться результата от метода getUser.

  • возвращаем виджет _ProfileTile для каждого пользователя, который аккуратно отображает информацию о пользователе и делает возможным подписываться и отписываться от других пользователей при помощи вызова метода followOrUnfollowUser. FeedBloc, в свою очередь, используется для возможности подписываться и отписываться от других пользователей на основе их ID;

  • методы followFeed и unfollowFeed  в FeedBloc имеют стандартные значения, заданные для того, чтобы использовать пользовательскую (user) и хронологическую (timeline) ленты. Если вы изначально задали другие названия для ваших лент, вам нужно изменить эти названия на соответствующие.

Метод followOrUnfollowUser также выполняет следующее:

FeedProvider.of(context).bloc.queryEnrichedActivities(
          feedGroup: 'timeline',
          flags: EnrichmentFlags()
            ..withOwnReactions()
            ..withRecentReactions()
            ..withReactionCounts(),
        );

В данной части кода мы форсируем обновление хронологической ленты (timeline feed). Интересным моментом здесь является аргумент Flags. Он дает сигнал API о том, чтобы получить данные о постах в ленте при помощи этого аргумента. Проще говоря, он запрашивает получение данных о постах и о реакциях добавленных на них. Позже в статье мы еще вернемся к этому.

Далее, создайте баррель-файл components/search/search.dart и как обычно экспортируйте его:

export 'search_page.dart';

Теперь нам нужно найти способ как перейти на нашу вкладку поиска. Создайте components/home/home_screen.dart и в виджете HomeScreen измените переменную _homePages следующим образом:

...
import 'package:stream_agram/components/search/search.dart';
...
/// List of pages available from the home screen.
static const List<Widget> _homePages = <Widget>[
  Center(child: Text('TimelinePage')),
  SearchPage(), // ADD THIS
  ProfilePage(),
];
...

Если вы все сделали правильно, теперь вы сможете переходить на вкладку поиска, которая будет выглядеть так:

⚠️ Внимание: если вы получаете ошибку с текстом «Could not load profile», это значит, что возможно вы еще не создали пользовательский аккаунт. Убедитесь, что вы авторизовались в приложении под каждым пользователем хотя бы один раз!

Воссоздаем хронологическую ленту

Сейчас пользователи нашего Stream-agram могут добавлять посты в свою пользовательскую ленту, а также подписываться и отписываться от конкретных пользователей таким же образом, как это реализовано в настоящем Instagram. Это значит - пришло время наконец взяться за хронологическую ленту.

Однако настроить UI и анимации так, чтобы они хотя бы просто работали - не такая уж и простая задача. Вместо того, чтобы сразу углубляться в эти дебри, давайте сначала просто создадим несколько UI виджетов, от которых мы будем опираться.

Создаем кнопку лайка и поле «Добавить комментарий»

Можете ли вы представить приложение соц. сеть такое как Instagram без возможности комментировать, ставить лайки и реакции? Для того чтобы внедрить данный функционал в наш клон, вам понадобится создать виджет, который будет реализовывать возможность проставления лайков, а также виджет для возможности оставлять комментарии к посту (виджет - TextField).

Давайте начнем с кнопки лайка/сердечка.

Создайте файл components/app_widgets/favorite_icon.dart и добавьте в него:

import 'package:flutter/material.dart';
import 'package:stream_agram/app/theme.dart';

/// {@template favorite_icon_button}
/// Animated button to indicate if a post/comment is liked.
///
/// Pass in onPressed to
/// {@endtemplate}
class FavoriteIconButton extends StatefulWidget {
  /// {@macro favorite_icon_button}
  const FavoriteIconButton({
    Key? key,
    required this.isLiked,
    this.size = 22,
    required this.onTap,
  }) : super(key: key);

  /// Indicates if it is liked or not.
  final bool isLiked;

  /// Size of the icon.
  final double size;

  /// onTap callback. Returns a value to indicate if liked or not.
  final Function(bool val) onTap;

  @override
  _FavoriteIconButtonState createState() => _FavoriteIconButtonState();
}

class _FavoriteIconButtonState extends State<FavoriteIconButton> {
  late bool isLiked = widget.isLiked;

  void _handleTap() {
    setState(() {
      isLiked = !isLiked;
    });
    widget.onTap(isLiked);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: AnimatedCrossFade(
        firstCurve: Curves.easeIn,
        secondCurve: Curves.easeOut,
        firstChild: Icon(
          Icons.favorite,
          color: AppColors.like,
          size: widget.size,
        ),
        secondChild: Icon(
          Icons.favorite_outline,
          size: widget.size,
        ),
        crossFadeState:
            isLiked ? CrossFadeState.showFirst : CrossFadeState.showSecond,
        duration: const Duration(milliseconds: 200),
      ),
    );
  }
}

Это простой виджет, который подсвечивает иконку сердечка, при нажатии на нее. Он использует функцию обратного вызова onTap. Этот виджет используется для того, чтобы визуально продемонстрировать то, что кто-то поставил лайк на пост или прокомментировал его.

Далее, создайте виджет CommentBox (для поля «Добавить комментарий»).

Создайте файл components/app_widgets/comment_box.dart  и добавьте в него:

import 'package:flutter/material.dart';

import '../../app/app.dart';
import 'app_widgets.dart';

/// Displays a text field styled to easily add comments to posts.
///
/// Quickly add emoji reactions.
class CommentBox extends StatelessWidget {
  /// Creates a [CommentBox].
  const CommentBox({
    Key? key,
    required this.commenter,
    required this.textEditingController,
    required this.focusNode,
    required this.onSubmitted,
  }) : super(key: key);

  final StreamagramUser commenter;
  final TextEditingController textEditingController;
  final FocusNode focusNode;
  final Function(String?) onSubmitted;

  @override
  Widget build(BuildContext context) {
    final border = _border(context);
    return Container(
      decoration: BoxDecoration(
        color: (Theme.of(context).brightness == Brightness.light)
            ? AppColors.light
            : AppColors.dark,
        border: Border(
            top: BorderSide(
          color: (Theme.of(context).brightness == Brightness.light)
              ? AppColors.ligthGrey
              : AppColors.grey,
        )),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 8.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                _emojiText('❤️'),
                _emojiText('????'),
                _emojiText('????'),
                _emojiText('????????'),
                _emojiText('????'),
                _emojiText('????'),
                _emojiText('????'),
                _emojiText('????'),
              ],
            ),
          ),
          Row(
            children: [
              Padding(
                padding: const EdgeInsets.all(8.0),
                child: Avatar.medium(streamagramUser: commenter),
              ),
              Expanded(
                child: TextField(
                  controller: textEditingController,
                  focusNode: focusNode,
                  onSubmitted: onSubmitted,
                  minLines: 1,
                  maxLines: 10,
                  style: const TextStyle(fontSize: 14),
                  decoration: InputDecoration(
                      suffix: _DoneButton(
                        textEditorFocusNode: focusNode,
                        textEditingController: textEditingController,
                        onSubmitted: onSubmitted,
                      ),
                      hintText: 'Add a comment...',
                      isDense: true,
                      contentPadding: const EdgeInsets.symmetric(
                          horizontal: 16, vertical: 12),
                      focusedBorder: border,
                      border: border,
                      enabledBorder: border),
                ),
              ),
              const SizedBox(
                width: 8,
              ),
            ],
          ),
        ],
      ),
    );
  }

  OutlineInputBorder _border(BuildContext context) {
    return OutlineInputBorder(
      borderRadius: const BorderRadius.all(Radius.circular(24)),
      borderSide: BorderSide(
        color: (Theme.of(context).brightness == Brightness.light)
            ? AppColors.grey.withOpacity(0.3)
            : AppColors.light.withOpacity(0.5),
        width: 0.5,
      ),
    );
  }

  Widget _emojiText(String emoji) {
    return GestureDetector(
      onTap: () {
        focusNode.requestFocus();
        textEditingController.text = textEditingController.text + emoji;
        textEditingController.selection = TextSelection.fromPosition(
            TextPosition(offset: textEditingController.text.length));
      },
      child: Text(
        emoji,
        style: const TextStyle(fontSize: 24),
      ),
    );
  }
}

class _DoneButton extends StatefulWidget {
  const _DoneButton({
    Key? key,
    required this.onSubmitted,
    required this.textEditorFocusNode,
    required this.textEditingController,
  }) : super(key: key);

  final Function(String?) onSubmitted;
  final FocusNode textEditorFocusNode;
  final TextEditingController textEditingController;

  @override
  State<_DoneButton> createState() => _DoneButtonState();
}

class _DoneButtonState extends State<_DoneButton> {
  final fadedTextStyle =
      AppTextStyle.textStyleAction.copyWith(color: Colors.grey);
  late TextStyle textStyle = fadedTextStyle;

  @override
  void initState() {
    super.initState();
    widget.textEditingController.addListener(() {
      if (widget.textEditingController.text.isNotEmpty) {
        textStyle = AppTextStyle.textStyleAction;
      } else {
        textStyle = fadedTextStyle;
      }
			if (mounted) {
	      setState(() {});
			}
    });
  }

  @override
  Widget build(BuildContext context) {
    return widget.textEditorFocusNode.hasFocus
        ? GestureDetector(
            onTap: () {
              widget.onSubmitted(widget.textEditingController.text);
            },
            child: Text(
              'Done',
              style: textStyle,
            ),
          )
        : const SizedBox.shrink();
  }
}

Вкратце, этот виджет выполняет следующее:

  • включает в себя класс StreamagramUser, который будет использован в качестве текущего пользователя или комментатора (commenter) для отображения главного фото пользователя. Также этот класс включает в себя FocusNode, TextEditingController и метод обратного вызова onSubmitted, каждый из которых используется в процессе редактирования текста. Метод onSubmitted будет вызван как только сообщение будет отправлено (после нажатия кнопки Done или return);

  • добавляет немного элементов интерфейса и стилей. К примеру, включает и выключает кнопку «Done» (Готово);

  • создает удобную в использованию панель с эмодзи, которая обновляет TextEditingController после нажатия на один из них.

Наконец, предоставим доступ к баррель-файлу components/app_widgets/app_widgets.dart.

export 'avatars.dart';
export 'tap_fade_icon.dart';
export 'favorite_icon.dart'; // ADD THIS
export 'comment_box.dart'; // ADD THIS

Создаем виджет PostCard

Приготовьтесь, сейчас будет много информации. Этот виджет отвечает за всё, что связано с единичным постом.

Подробное описание этого виджета займет уйму времени. Вместо этого, предлагаю просто взглянуть на картинку. ????

В создании всего, что вы видите выше, так или иначе будет задействован виджет PostCard. Мы имеем ввиду:

  • имя пользователя в заголовке к посту;

  • само изображение (пост);

  • кнопку поставить лайк и открыть комментарии (кнопки поделиться и добавить в закладки - всего лишь изображения, не несущие функционала);

  • описание к фото;

  • поле «Добавить комментарий» (Add a comment).

Для  начала создайте файл components/timeline/widgets/post_card.dart и добавьте следующий код:

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:provider/provider.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import '../../../app/app.dart';
import '../../app_widgets/app_widgets.dart';

typedef OnAddComment = void Function(
  EnrichedActivity activity, {
  String? message,
});

/// {@template post_card}
/// A card that displays a user post/activity.
/// {@endtemplate}
class PostCard extends StatelessWidget {
  /// {@macro post_card}
  const PostCard({
    Key? key,
    required this.enrichedActivity,
    required this.onAddComment,
  }) : super(key: key);

  /// Enriched activity (post) to display.
  final EnrichedActivity enrichedActivity;
  final OnAddComment onAddComment;

  @override
  Widget build(BuildContext context) {
    final actorData = enrichedActivity.actor!.data;
    final userData = StreamagramUser.fromMap(actorData as Map<String, dynamic>);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _ProfileSlab(
          userData: userData,
        ),
        _PictureCarousal(
          enrichedActivity: enrichedActivity,
        ),
        _Description(
          enrichedActivity: enrichedActivity,
        ),
        _InteractiveCommentSlab(
          enrichedActivity: enrichedActivity,
          onAddComment: onAddComment,
        ),
      ],
    );
  }
}

class _PictureCarousal extends StatefulWidget {
  const _PictureCarousal({
    Key? key,
    required this.enrichedActivity,
  }) : super(key: key);

  final EnrichedActivity enrichedActivity;

  @override
  __PictureCarousalState createState() => __PictureCarousalState();
}

class __PictureCarousalState extends State<_PictureCarousal> {
  late var likeReactions = getLikeReactions() ?? [];
  late var likeCount = getLikeCount() ?? 0;

  Reaction? latestLikeReaction;

  List<Reaction>? getLikeReactions() {
    return widget.enrichedActivity.latestReactions?['like'] ?? [];
  }

  int? getLikeCount() {
    return widget.enrichedActivity.reactionCounts?['like'] ?? 0;
  }

  Future<void> _addLikeReaction() async {
    latestLikeReaction = await context.appState.client.reactions.add(
      'like',
      widget.enrichedActivity.id!,
      userId: context.appState.user.id,
    );

    setState(() {
      likeReactions.add(latestLikeReaction!);
      likeCount++;
    });
  }

  Future<void> _removeLikeReaction() async {
    late String? reactionId;
    // A new reaction was added to this state.
    if (latestLikeReaction != null) {
      reactionId = latestLikeReaction?.id;
    } else {
      // An old reaction has been retrieved from Stream.
      final prevReaction = widget.enrichedActivity.ownReactions?['like'];
      if (prevReaction != null && prevReaction.isNotEmpty) {
        reactionId = prevReaction[0].id;
      }
    }

    try {
      if (reactionId != null) {
        await context.appState.client.reactions.delete(reactionId);
      }
    } catch (e) {
      debugPrint(e.toString());
    }
    setState(() {
      likeReactions.removeWhere((element) => element.id == reactionId);
      likeCount--;
      latestLikeReaction = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        ..._pictureCarousel(context),
        _likes(),
      ],
    );
  }

  /// Picture carousal and interaction buttons.
  List<Widget> _pictureCarousel(BuildContext context) {
    const iconPadding = EdgeInsets.symmetric(horizontal: 8, vertical: 4);
    var imageUrl = widget.enrichedActivity.extraData!['image_url'] as String;
    double aspectRatio =
        widget.enrichedActivity.extraData!['aspect_ratio'] as double? ?? 1.0;
    final iconColor = Theme.of(context).iconTheme.color!;
    return [
      Padding(
        padding: const EdgeInsets.symmetric(vertical: 8.0),
        child: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxHeight: 500),
            child: AspectRatio(
              aspectRatio: aspectRatio,
              child: CachedNetworkImage(
                imageUrl: imageUrl,
              ),
            ),
          ),
        ),
      ),
      Row(
        children: [
          const SizedBox(
            width: 4,
          ),
          Padding(
            padding: iconPadding,
            child: FavoriteIconButton(
              isLiked: widget.enrichedActivity.ownReactions?['like'] != null,
              onTap: (liked) {
                if (liked) {
                  _addLikeReaction();
                } else {
                  _removeLikeReaction();
                }
              },
            ),
          ),
          Padding(
            padding: iconPadding,
            child: TapFadeIcon(
              onTap: () {
                // TODO
              },
              icon: Icons.chat_bubble_outline,
              iconColor: iconColor,
            ),
          ),
          Padding(
            padding: iconPadding,
            child: TapFadeIcon(
              onTap: () =>
                  context.removeAndShowSnackbar('Message: Not yet implemented'),
              icon: Icons.call_made,
              iconColor: iconColor,
            ),
          ),
          const Spacer(),
          Padding(
            padding: iconPadding,
            child: TapFadeIcon(
              onTap: () => context
                  .removeAndShowSnackbar('Bookmark: Not yet implemented'),
              icon: Icons.bookmark_border,
              iconColor: iconColor,
            ),
          ),
        ],
      )
    ];
  }

  Widget _likes() {
    if (likeReactions.isNotEmpty) {
      return Padding(
        padding: const EdgeInsets.only(left: 16.0, top: 8),
        child: Text.rich(
          TextSpan(
            text: 'Liked by ',
            style: AppTextStyle.textStyleLight,
            children: <TextSpan>[
              TextSpan(
                  text: StreamagramUser.fromMap(
                          likeReactions[0].user?.data as Map<String, dynamic>)
                      .fullName,
                  style: AppTextStyle.textStyleBold),
              if (likeCount > 1 && likeCount < 3) ...[
                const TextSpan(text: ' and '),
                TextSpan(
                    text: StreamagramUser.fromMap(
                            likeReactions[1].user?.data as Map<String, dynamic>)
                        .fullName,
                    style: AppTextStyle.textStyleBold),
              ],
              if (likeCount > 3) ...[
                const TextSpan(text: ' and '),
                const TextSpan(
                    text: 'others', style: AppTextStyle.textStyleBold),
              ],
            ],
          ),
        ),
      );
    } else {
      return const SizedBox.shrink();
    }
  }
}

class _Description extends StatelessWidget {
  const _Description({
    Key? key,
    required this.enrichedActivity,
  }) : super(key: key);

  final EnrichedActivity enrichedActivity;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6),
      child: Text.rich(
        TextSpan(
          children: <TextSpan>[
            TextSpan(
                text: enrichedActivity.actor!.id!,
                style: AppTextStyle.textStyleBold),
            const TextSpan(text: ' '),
            TextSpan(
                text: enrichedActivity.extraData?['description'] as String? ??
                    ''),
          ],
        ),
      ),
    );
  }
}

class _InteractiveCommentSlab extends StatefulWidget {
  const _InteractiveCommentSlab({
    Key? key,
    required this.enrichedActivity,
    required this.onAddComment,
  }) : super(key: key);

  final EnrichedActivity enrichedActivity;
  final OnAddComment onAddComment;

  @override
  _InteractiveCommentSlabState createState() => _InteractiveCommentSlabState();
}

class _InteractiveCommentSlabState extends State<_InteractiveCommentSlab> {
  EnrichedActivity get enrichedActivity => widget.enrichedActivity;

  late final String _timeSinceMessage =
      Jiffy(widget.enrichedActivity.time).fromNow();

  List<Reaction> get _commentReactions =>
      enrichedActivity.latestReactions?['comment'] ?? [];

  int get _commentCount => enrichedActivity.reactionCounts?['comment'] ?? 0;

  @override
  Widget build(BuildContext context) {
    const textPadding = EdgeInsets.all(8);
    const spacePadding = EdgeInsets.only(left: 20.0, top: 8);
    final comments = _commentReactions;
    final commentCount = _commentCount;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (commentCount > 0 && comments.isNotEmpty)
          Padding(
            padding: spacePadding,
            child: Text.rich(
              TextSpan(
                children: <TextSpan>[
                  TextSpan(
                      text: StreamagramUser.fromMap(
                              comments[0].user?.data as Map<String, dynamic>)
                          .fullName,
                      style: AppTextStyle.textStyleBold),
                  const TextSpan(text: '  '),
                  TextSpan(text: comments[0].data?['message'] as String?),
                ],
              ),
            ),
          ),
        if (commentCount > 1 && comments.isNotEmpty)
          Padding(
            padding: spacePadding,
            child: Text.rich(
              TextSpan(
                children: <TextSpan>[
                  TextSpan(
                      text: StreamagramUser.fromMap(
                              comments[1].user?.data as Map<String, dynamic>)
                          .fullName,
                      style: AppTextStyle.textStyleBold),
                  const TextSpan(text: '  '),
                  TextSpan(text: comments[1].data?['message'] as String?),
                ],
              ),
            ),
          ),
        if (commentCount > 2)
          Padding(
            padding: spacePadding,
            child: GestureDetector(
              onTap: () {
                // TODO
              },
              child: Text(
                'View all $commentCount comments',
                style: AppTextStyle.textStyleFaded,
              ),
            ),
          ),
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: () {
            widget.onAddComment(enrichedActivity);
          },
          child: Padding(
            padding: const EdgeInsets.only(left: 16.0, top: 3, right: 8),
            child: Row(
              children: [
                const _ProfilePicture(),
                const Expanded(
                  child: Padding(
                    padding: EdgeInsets.only(left: 8.0),
                    child: Text(
                      'Add a comment',
                      style: TextStyle(
                        color: AppColors.faded,
                        fontSize: 14,
                      ),
                    ),
                  ),
                ),
                GestureDetector(
                  onTap: () {
                    widget.onAddComment(enrichedActivity, message: '❤️');
                  },
                  child: const Padding(
                    padding: textPadding,
                    child: Text('❤️'),
                  ),
                ),
                GestureDetector(
                  onTap: () {
                    widget.onAddComment(enrichedActivity, message: '????');
                  },
                  child: const Padding(
                    padding: textPadding,
                    child: Text('????'),
                  ),
                ),
              ],
            ),
          ),
        ),
        Padding(
          padding: const EdgeInsets.only(left: 16.0, top: 4),
          child: Text(
            _timeSinceMessage,
            style: const TextStyle(
              color: AppColors.faded,
              fontWeight: FontWeight.w400,
              fontSize: 13,
            ),
          ),
        ),
      ],
    );
  }
}

class _ProfilePicture extends StatelessWidget {
  const _ProfilePicture({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final streamagramUser = context.watch<AppState>().streamagramUser;
    if (streamagramUser == null) {
      return const Icon(Icons.error);
    }
    return Avatar.small(
      streamagramUser: streamagramUser,
    );
  }
}

class _ProfileSlab extends StatelessWidget {
  const _ProfileSlab({
    Key? key,
    required this.userData,
  }) : super(key: key);

  final StreamagramUser userData;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
      child: Row(
        children: [
          Avatar.medium(streamagramUser: userData),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(
              userData.fullName,
              style: AppTextStyle.textStyleBold,
            ),
          ),
          const Spacer(),
          TapFadeIcon(
            onTap: () => context.removeAndShowSnackbar('Not part of the demo'),
            icon: Icons.more_horiz,
            iconColor: Theme.of(context).iconTheme.color!,
          ),
        ],
      ),
    );
  }
}

В этом файле слишком много всего, что нужно подробно осветить. Самое важное, что мы здесь делаем это:

  • передаем EnrichedActivity к PostCard. Класс Enriched Activity здесь по сути является обычным классом Activity, только на стероидах. Первый содержит в себе дополнительную информацию (например - реакции). Реакции могут быть в виде комментариев или лайков;

  • передаем функцию обратного вызова OnAddComment. Эта функция вызывается тогда, когда нажимается кнопка «Добавить комментарий» (к посту).  Мы добавили эту функцию в код, чтобы когда пользователь нажимал на эмодзи, сигнал об этом сразу же передавался виджету CommentBox. И теперь если пользователь нажимает на эмодзи, он сразу попадает в поле комментария, где он может либо отправить выбранный эмодзи нажав на Done, либо продолжить набор комментария;

  • локально управляем состоянием реакций, добавленных к постам. Гораздо проще управлять им самому, чем изменять StreamFeedBloc. Теперь лист тех, кто оставил лайк на посте будет обновляться как только будет поставлен новый лайк.

Отдельного внимания стоит метод _addLikeReaction в виджете _PictureCarousal, который мы вам рекомендуем изучить поглубже.

Если вам нужно больше информации по этой части кода, не забывайте про видео, прикрепленное выше.

Далее, как обычно, создайте новый баррель-файл components/timeline/widgets/widgets.dart и экспортируйте его:

export 'post_card.dart';

Переходим к созданию хронологической ленты

Еще один большой раздел. На этой странице, мы будем использовать виджет FlatFeedCore для того, чтобы отобразить хронологическую ленту.  Для каждого посте в ленте, мы отобразим виджет PostCard. Здесь, нам также потребуется немного магии, чтобы создать плавающее поле «Добавить комментарий» (CommentBox) при пролистывании ленты.

Создайте файл components/timeline/timeline_page.dart и добавьте:

import 'package:flutter/material.dart';
import 'package:stream_agram/app/app.dart';
import 'package:stream_agram/components/app_widgets/app_widgets.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import 'widgets/widgets.dart';

/// {@template timeline_page}
/// Page to display a timeline of user created posts. Global 'timeline'
/// {@endtemplate}
class TimelinePage extends StatefulWidget {
  /// {@macro timeline_page}
  const TimelinePage({Key? key}) : super(key: key);

  @override
  State<TimelinePage> createState() => _TimelinePageState();
}

class _TimelinePageState extends State<TimelinePage> {
  final ValueNotifier<bool> _showCommentBox = ValueNotifier(false);
  final TextEditingController _commentTextController = TextEditingController();
  final FocusNode _commentFocusNode = FocusNode();
  EnrichedActivity? activeActivity;

  void openCommentBox(EnrichedActivity activity, {String? message}) {
    _commentTextController.text = message ?? '';
    _commentTextController.selection = TextSelection.fromPosition(
        TextPosition(offset: _commentTextController.text.length));
    activeActivity = activity;
    _showCommentBox.value = true;
    _commentFocusNode.requestFocus();
  }

  Future<void> addComment(String? message) async {
    if (activeActivity != null &&
        message != null &&
        message.isNotEmpty &&
        message != '') {
      await FeedProvider.of(context).bloc.onAddReaction(
        kind: 'comment',
        activity: activeActivity!,
        feedGroup: 'timeline',
        data: {'message': message},
      );
      _commentTextController.clear();
      FocusScope.of(context).unfocus();
      _showCommentBox.value = false;
    }
  }

  @override
  void dispose() {
    _commentTextController.dispose();
    _commentFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        FocusScope.of(context).unfocus();
        _showCommentBox.value = false;
      },
      child: Stack(
        children: [
          FlatFeedCore(
            feedGroup: 'timeline',
            errorBuilder: (context, error) =>
                const Text('Could not load profile'),
            loadingBuilder: (context) => const SizedBox(),
            emptyBuilder: (context) => const Center(
              child: Text('No Posts\nGo and post something'),
            ),
            flags: EnrichmentFlags()
              ..withOwnReactions()
              ..withRecentReactions()
              ..withReactionCounts(),
            feedBuilder: (context, activities) {
              return RefreshIndicator(
                onRefresh: () {
                  return FeedProvider.of(context).bloc.queryEnrichedActivities(
                        feedGroup: 'timeline',
                        flags: EnrichmentFlags()
                          ..withOwnReactions()
                          ..withRecentReactions()
                          ..withReactionCounts(),
                      );
                },
                child: ListView.builder(
                  itemCount: activities.length,
                  itemBuilder: (context, index) {
                    return PostCard(
                      key: ValueKey('post-${activities[index].id}'),
                      enrichedActivity: activities[index],
                      onAddComment: openCommentBox,
                    );
                  },
                ),
              );
            },
          ),
          _CommentBox(
            commenter: context.appState.streamagramUser!,
            textEditingController: _commentTextController,
            focusNode: _commentFocusNode,
            addComment: addComment,
            showCommentBox: _showCommentBox,
          )
        ],
      ),
    );
  }
}

class _CommentBox extends StatefulWidget {
  const _CommentBox({
    Key? key,
    required this.commenter,
    required this.textEditingController,
    required this.focusNode,
    required this.addComment,
    required this.showCommentBox,
  }) : super(key: key);

  final StreamagramUser commenter;
  final TextEditingController textEditingController;
  final FocusNode focusNode;
  final Function(String?) addComment;
  final ValueNotifier<bool> showCommentBox;

  @override
  __CommentBoxState createState() => __CommentBoxState();
}

class __CommentBoxState extends State<_CommentBox>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  late final Animation<double> _animation = CurvedAnimation(
    parent: _controller,
    curve: Curves.easeOut,
    reverseCurve: Curves.easeIn,
  );

  bool visibility = false;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.dismissed) {
        setState(() {
          visibility = false;
        });
      } else {
        setState(() {
          visibility = true;
        });
      }
    });
    widget.showCommentBox.addListener(_showHideCommentBox);
  }

  void _showHideCommentBox() {
    if (widget.showCommentBox.value) {
      _controller.forward();
    } else {
      _controller.reverse();
    }
  }

  @override
  void dispose() {
    super.dispose();
    _controller.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Visibility(
      visible: visibility,
      child: FadeTransition(
        opacity: _animation,
        child: Builder(builder: (context) {
          return Align(
            alignment: Alignment.bottomCenter,
            child: CommentBox(
              commenter: widget.commenter,
              textEditingController: widget.textEditingController,
              focusNode: widget.focusNode,
              onSubmitted: widget.addComment,
            ),
          );
        }),
      ),
    );
  }
}

Самая важная часть раздела.

Здесь мы создаем Stack, первым элементом которого является FlatFeedCore. При этом, для feedGroup мы устанавливаем значение «timeline». Это все очень похоже на то, что мы делали для главного профиля (ProfilePage). Однако тут мы:

  • возвращаем PostCard в виджете feedBuilder;

  • устанавливаем аргумент flags вместе с EnrichmentFlags. Благодаря этому FlatFeedCore создаст EnrichedActivities (обновленные посты), которые будут содержать все текущие реакции пользователей(withOwnReactions), все недавние реакции(withRecentReactions)и счетчик реакций (withReactionCounts).

Второй и в то же время последний элемент в Stack - это _CommentBox. Это особый виджет, который помогает создать нам плавную анимацию раскрытия поля «Добавить комментарий» (CommentBox) вместе с клавиатурой при нажатии на него. Он также сворачивает это поле если кликнуть на любое другое место.

  • Анимация вызывается тогда, когда меняется значение в _showCommentBox.

Еще несколько важных моментов про этот файл:

  • метод addComment использует FeedBloc для того, чтобы добавлять реакции на пост (activity). Реакция, которую мы добавляем - комментарий (comment). Добавляем мы его в хронологическую ленту (timeline). String сообщение выступает в качестве дополнительных данных;

  • метод openCommentBox вызывается когда пользователь нажимает пальцем на поле комментария или выбирает один из эмодзи. Данный метод отвечает за то, чтобы CommentBox (поле «Добавить комментарий») было отображено и находилось в фокусе; 

  •  _CommentBox использует явную анимацию потому что мы хотим, чтобы значение Visibility было равно false в моменте, когда анимация завершается и поле комментария должно быть скрыто. Это улучшает производительность, поскольку мы не хотим, чтобы Flutter проходил через процесс создания CommentBox и, далее, устанавливал нулевое значения для прозрачности (opacity), когда это не требуется. Использование FadeTransition - наиболее оптимальный вариант для настройки прозрачности для анимаций во Flutter;

Фух ????‍???? можно выдохнуть. Всё почти готово.

Создайте новый баррель-файл components/timeline/timeline.dart и добавьте:

export 'timeline_page.dart';

Далее, откройте components/home/home_screen.dart и измените переменную _homePages как на фрагменте ниже:

...
import 'package:stream_agram/components/timeline/timeline_page.dart';
...
/// List of pages available from the home screen.
static const List<Widget> _homePages = <Widget>[
  TimelinePage(), // ADD THIS
  SearchPage(),
  ProfilePage(),
];
...

Теперь наконец вы можете запустить свой Instagram и посмотреть на посты в вашей хронологической ленте (если вы конечно подписались на кого-то, кто их опубликовал). Вы также должны иметь возможность оставлять лайки и добавлять комментарии, а лента при этом будет обновляться автоматически. ????

Добавляем больше комментариев и лайков

Открывая пост в Instagram*, вы ожидаете увидеть под ним все оставленные. В нашем клоне мы создадим секцию комментариев, которую можно будет развернуть, чтобы посмотреть абсолютно все комментарии под постом. Мы также сделаем так, чтобы пользователи могли отвечать на другие комментарии и ставить лайки на сами комментарии ????. Это называется дочерние реакции.

Эта секция - комбинация всего, что мы изучили выше.

Состояние комментария

Здесь нам понадобится немного изощренное управление состоянием, поскольку нам нужно будет отследить добавляет ли пользователь свой комментарий (реакцию) к посту или к другому комментарию под ним.

Создайте components/comments/state/comment_state.dart и добавьте:

import 'package:flutter/material.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import '../../../app/state/models/models.dart';

/// Indicates the type of comment that was made.
/// Can be:
/// - Activity comment
/// - Reaction comment
enum TypeOfComment {
  /// Comment on an activity
  activityComment,

  /// Comment on a reaction
  reactionComment,
}

/// {@template comment_focus}
/// Information on the type of comment to make. This can be a comment on an
/// activity, or a comment on a reaction.
///
/// It also indicates the parent user on whom the comment is made.
/// {@endtemplate}
class CommentFocus {
  /// {@macro comment_focus}
  const CommentFocus({
    required this.typeOfComment,
    required this.id,
    required this.user,
    this.reaction,
  });

  final Reaction? reaction;

  /// Indicates the type of comment. See [TypeOfComment].
  final TypeOfComment typeOfComment;

  /// Activity or reaction id on which the comment is made.
  final String id;

  /// The user data of the parent activity or reaction.
  final StreamagramUser user;
}

/// {@template comment_state}
/// ChangeNotifier to facilitate posting comments to activities and reactions.
/// {@endtemplate}
class CommentState extends ChangeNotifier {
  /// {@macro comment_state}
  CommentState({
    required this.activityId,
    required this.activityOwnerData,
  });

  /// The id for this activity.
  final String activityId;

  /// UserData of whoever owns the activity.
  final StreamagramUser activityOwnerData;

  /// The type of commentFocus that is currently selected.
  late CommentFocus commentFocus = CommentFocus(
    typeOfComment: TypeOfComment.activityComment,
    id: activityId,
    user: activityOwnerData,
  );

  /// Sets the focus to which a comment will be posted to.
  ///
  /// See [postComment].
  void setCommentFocus(CommentFocus focus) {
    commentFocus = focus;
    notifyListeners();
  }

  /// Resets the comment focus to the parent activity.
  void resetCommentFocus() {
    commentFocus = CommentFocus(
      typeOfComment: TypeOfComment.activityComment,
      id: activityId,
      user: activityOwnerData,
    );
    notifyListeners();
  }
}

Значение ChangeNotifier зависит от типа комментария (TypeOfComment). Есть два типа комментариев:

  • комментарий (либо лайк) к посту;

  • комментарий (либо лайк) к другому комментарию.

Это будет проще понять, взглянув на UI код.

Откройте доступ к баррель-файлу components/comments/state/state.dart:

export 'comment_state.dart';

Создайте секцию комментариев как в Instagram*

Создайте components/comments/comment_screen.dart файл и добавьте:

import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:provider/provider.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import '../../app/app.dart';
import '../app_widgets/app_widgets.dart';
import 'state/comment_state.dart';

/// Screen that shows all comments for a given post.
class CommentsScreen extends StatefulWidget {
  /// Creates a new [CommentsScreen].
  const CommentsScreen({
    Key? key,
    required this.enrichedActivity,
    required this.activityOwnerData,
  }) : super(key: key);

  final EnrichedActivity enrichedActivity;

  /// Owner / [User] of the activity.
  final StreamagramUser activityOwnerData;

  /// MaterialPageRoute to this screen.
  static Route route({
    required EnrichedActivity enrichedActivity,
    required StreamagramUser activityOwnerData,
  }) =>
      MaterialPageRoute(
        builder: (context) => CommentsScreen(
          enrichedActivity: enrichedActivity,
          activityOwnerData: activityOwnerData,
        ),
      );

  @override
  _CommentsScreenState createState() => _CommentsScreenState();
}

class _CommentsScreenState extends State<CommentsScreen> {
  late FocusNode commentFocusNode;
  late CommentState commentState;

  @override
  void initState() {
    super.initState();
    commentFocusNode = FocusNode();
    commentState = CommentState(
      activityId: widget.enrichedActivity.id!,
      activityOwnerData: widget.activityOwnerData,
    );
  }

  @override
  void dispose() {
    commentFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider.value(value: commentState),
        ChangeNotifierProvider.value(value: commentFocusNode),
      ],
      child: GestureDetector(
        onTap: () {
          commentState.resetCommentFocus();
          FocusScope.of(context).unfocus();
        },
        child: Scaffold(
          appBar: AppBar(
            title: const Text('Comments',
                style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
            elevation: 0.5,
            shadowColor: Colors.white,
          ),
          body: Stack(
            children: [
              _CommentsList(
                activityId: widget.enrichedActivity.id!,
              ),
              _CommentBox(
                enrichedActivity: widget.enrichedActivity,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _CommentsList extends StatelessWidget {
  const _CommentsList({
    Key? key,
    required this.activityId,
  }) : super(key: key);

  final String activityId;

  @override
  Widget build(BuildContext context) {
    return ReactionListCore(
      lookupValue: activityId,
      kind: 'comment',
      loadingBuilder: (context) =>
          const Center(child: CircularProgressIndicator()),
      errorBuilder: (context, error) =>
          const Center(child: Text('Could not load comments.')),
      emptyBuilder: (context) =>
          const Center(child: Text('Be the first to add a comment.')),
      reactionsBuilder: (context, reactions) {
        return ListView.builder(
          itemCount: reactions.length + 1,
          itemBuilder: (context, index) {
            if (index == reactions.length) {
              // Bottom padding to ensure [CommentBox] does not obscure
              // visibility
              return const SizedBox(
                height: 120,
              );
            }
            return Padding(
              padding: const EdgeInsets.symmetric(vertical: 8.0),
              child: _CommentTile(
                key: ValueKey('comment-${reactions[index].id}'),
                reaction: reactions[index],
              ),
            );
          },
        );
      },
      flags: EnrichmentFlags()
        ..withOwnChildren()
        ..withOwnReactions()
        ..withRecentReactions(),
    );
  }
}

class _CommentBox extends StatefulWidget {
  const _CommentBox({
    Key? key,
    required this.enrichedActivity,
  }) : super(key: key);

  final EnrichedActivity enrichedActivity;

  @override
  __CommentBoxState createState() => __CommentBoxState();
}

class __CommentBoxState extends State<_CommentBox> {
  late final _commentTextController = TextEditingController();

  Future<void> handleSubmit(String? value) async {
    if (value != null && value.isNotEmpty) {
      _commentTextController.clear();
      FocusScope.of(context).unfocus();

      final commentState = context.read<CommentState>();
      final commentFocus = commentState.commentFocus;

      if (commentFocus.typeOfComment == TypeOfComment.activityComment) {
        await FeedProvider.of(context).bloc.onAddReaction(
          kind: 'comment',
          activity: widget.enrichedActivity,
          feedGroup: 'timeline',
          data: {'message': value},
        );
      } else if (commentFocus.typeOfComment == TypeOfComment.reactionComment) {
        if (commentFocus.reaction != null) {
          await FeedProvider.of(context).bloc.onAddChildReaction(
            kind: 'comment',
            reaction: commentFocus.reaction!,
            activity: widget.enrichedActivity,
            data: {'message': value},
          );
        }
      }
    }
  }

  @override
  void dispose() {
    _commentTextController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final commentFocus =
        context.select((CommentState state) => state.commentFocus);

    final focusNode = context.watch<FocusNode>();

    return Align(
      alignment: Alignment.bottomCenter,
      child: Container(
        color: (Theme.of(context).brightness == Brightness.light)
            ? AppColors.light
            : AppColors.dark,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedSwitcher(
              duration: const Duration(milliseconds: 200),
              transitionBuilder: (child, animation) {
                final tween =
                    Tween(begin: const Offset(0.0, 1.0), end: Offset.zero)
                        .chain(CurveTween(curve: Curves.easeOutQuint));
                final offsetAnimation = animation.drive(tween);
                return SlideTransition(
                  position: offsetAnimation,
                  child: child,
                );
              },
              child:
                  (commentFocus.typeOfComment == TypeOfComment.reactionComment)
                      ? _replyToBox(commentFocus, context)
                      : const SizedBox.shrink(),
            ),
            CommentBox(
              commenter: context.appState.streamagramUser!,
              textEditingController: _commentTextController,
              onSubmitted: handleSubmit,
              focusNode: focusNode,
            ),
            SizedBox(
              height: MediaQuery.of(context).padding.bottom,
            )
          ],
        ),
      ),
    );
  }

  Container _replyToBox(CommentFocus commentFocus, BuildContext context) {
    return Container(
      color: (Theme.of(context).brightness == Brightness.dark)
          ? AppColors.grey
          : AppColors.ligthGrey,
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Row(
          children: [
            Text(
              'Replying to ${commentFocus.user.fullName}',
              style: AppTextStyle.textStyleFaded,
            ),
            const Spacer(),
            TapFadeIcon(
              onTap: () {
                context.read<CommentState>().resetCommentFocus();
              },
              icon: Icons.close,
              size: 16,
              iconColor: Theme.of(context).iconTheme.color!,
            ),
          ],
        ),
      ),
    );
  }
}

class _CommentTile extends StatefulWidget {
  const _CommentTile({
    Key? key,
    required this.reaction,
    this.canReply = true,
    this.isReplyToComment = false,
  }) : super(key: key);

  final Reaction reaction;
  final bool canReply;
  final bool isReplyToComment;
  @override
  __CommentTileState createState() => __CommentTileState();
}

class __CommentTileState extends State<_CommentTile> {
  late final userData = StreamagramUser.fromMap(widget.reaction.user!.data!);
  late final message = extractMessage;

  late final timeSince = _timeSinceComment();

  late int numberOfLikes = widget.reaction.childrenCounts?['like'] ?? 0;

  late bool isLiked = _isFavorited();
  Reaction? likeReaction;

  String _timeSinceComment() {
    final jiffyTime = Jiffy(widget.reaction.createdAt).fromNow();
    if (jiffyTime == 'a few seconds ago') {
      return 'just now';
    } else {
      return jiffyTime;
    }
  }

  String numberOfLikesMessage(int count) {
    if (count == 0) {
      return '';
    }
    if (count == 1) {
      return '1 like';
    } else {
      return '$count likes';
    }
  }

  String get extractMessage {
    final data = widget.reaction.data;
    if (data != null && data['message'] != null) {
      return data['message'] as String;
    } else {
      return '';
    }
  }

  bool _isFavorited() {
    likeReaction = widget.reaction.ownChildren?['like']?.first;
    return likeReaction != null;
  }

  Future<void> _handleFavorite(bool liked) async {
    if (isLiked && likeReaction != null) {
      await context.appState.client.reactions.delete(likeReaction!.id!);
      numberOfLikes--;
    } else {
      likeReaction = await context.appState.client.reactions.addChild(
        'like',
        widget.reaction.id!,
        userId: context.appState.user.id,
      );
      numberOfLikes++;
    }
    setState(() {
      isLiked = liked;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 8.0),
              child: (widget.isReplyToComment)
                  ? Avatar.tiny(streamagramUser: userData)
                  : Avatar.small(streamagramUser: userData),
            ),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Row(
                    children: [
                      Expanded(
                        child: Text.rich(
                          TextSpan(
                            children: <TextSpan>[
                              TextSpan(
                                  text: userData.fullName,
                                  style: AppTextStyle.textStyleSmallBold),
                              const TextSpan(text: ' '),
                              TextSpan(
                                text: message,
                                style: const TextStyle(fontSize: 13),
                              ),
                            ],
                          ),
                        ),
                      ),
                      Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 8.0),
                        child: Center(
                          child: FavoriteIconButton(
                            isLiked: isLiked,
                            size: 14,
                            onTap: _handleFavorite,
                          ),
                        ),
                      )
                    ],
                  ),
                  Padding(
                    padding: const EdgeInsets.only(top: 4.0),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: [
                        SizedBox(
                          width: 80,
                          child: Text(
                            timeSince,
                            style: AppTextStyle.textStyleFadedSmall,
                          ),
                        ),
                        Visibility(
                          visible: numberOfLikes > 0,
                          child: SizedBox(
                            width: 60,
                            child: Text(
                              numberOfLikesMessage(numberOfLikes),
                              style: AppTextStyle.textStyleFadedSmall,
                            ),
                          ),
                        ),
                        Visibility(
                          visible: widget.canReply,
                          child: GestureDetector(
                            onTap: () {
                              context.read<CommentState>().setCommentFocus(
                                    CommentFocus(
                                      typeOfComment:
                                          TypeOfComment.reactionComment,
                                      id: widget.reaction.id!,
                                      user: StreamagramUser.fromMap(
                                          widget.reaction.user!.data!),
                                      reaction: widget.reaction,
                                    ),
                                  );

                              FocusScope.of(context)
                                  .requestFocus(context.read<FocusNode>());
                            },
                            child: const SizedBox(
                              width: 50,
                              child: Text(
                                'Reply',
                                style: AppTextStyle.textStyleFadedSmallBold,
                              ),
                            ),
                          ),
                        )
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.only(left: 34.0),
          child: _ChildCommentList(
              comments: widget.reaction.latestChildren?['comment']),
        ),
      ],
    );
  }
}

class _ChildCommentList extends StatelessWidget {
  const _ChildCommentList({
    Key? key,
    required this.comments,
  }) : super(key: key);

  final List<Reaction>? comments;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: comments
              ?.map(
                (reaction) => Padding(
                  padding: const EdgeInsets.only(top: 8.0),
                  child: _CommentTile(
                    key: ValueKey('comment-tile-${reaction.id}'),
                    reaction: reaction,
                    canReply: false,
                    isReplyToComment: true,
                  ),
                ),
              )
              .toList() ??
          [],
    );
  }
}

Несколько важных моментов про этот файл:

  • здесь мы открываем доступ к FocusNode и CommentState, используя  MultiProvider для всех дочерних виджетов CommentScreen;

  • создаем Stack при помощи private виджетов _CommentList и _CommentBox;

  • _CommentList здесь использует ReactionListCore для того, чтобы отобразить все реакции к отдельному посту (Activity). По такому же принципу мы указываем EnrichmentFlags;

  • также мы находим применение AnimatedSwitcher для того, чтобы отобразить всплывающее окно в случае, когда мы отвечаем на комментарий другого пользователя;

  • используем метод _handleFavorite, чтобы ставить лайки на другие комментарии;

  • выводим на показ возраст комментария при помощи пакета Jiffy;

  • метод handleSubmit определяетявляется ли комментарий обычным или дочерним и добавляет его в секцию комментариев, используя FeedBloc.

Создайте баррель-файл components/comments/comments.dart и добавьте:

export 'comment_screen.dart';

Теперь исправьте текст в полях TODO комментариев в components/timeline/widgets/post_card.dart, чтобы иметь возможность переходить к экрану секции комментариев CommentsScreen.

...

import '../../comments/comments.dart';

...

Padding(
  padding: iconPadding,
  child: TapFadeIcon(
    onTap: () {
			// ADD THIS
      final map = widget.enrichedActivity.actor!.data!;

			// AND THIS
      Navigator.of(context).push(
        CommentsScreen.route(
          enrichedActivity: widget.enrichedActivity,
          activityOwnerData: StreamagramUser.fromMap(map),
        ),
      );
    },
    icon: Icons.chat_bubble_outline,
    iconColor: iconColor,
  ),
),

...

if (commentCount > 2)
  Padding(
    padding: spacePadding,
    child: GestureDetector(
      onTap: () {
				// ADD THIS
        final map =
            widget.enrichedActivity.actor!.data as Map<String, dynamic>;
				// AND THIS
        Navigator.of(context).push(CommentsScreen.route(
          enrichedActivity: widget.enrichedActivity,
          activityOwnerData: StreamagramUser.fromMap(map),
        ));
      },
      child: Text(
        'View all $commentCount comments',
        style: AppTextStyle.textStyleFaded,
      ),
    ),
  ),

Добавляем свою собственную пользовательскую ленту в хронологическую

Возможно вы уже заметили, что  когда мы открываем хронологическую ленту, мы не видим посты текущего авторизованного пользователя.

Это все потому что наше приложение еще не знает, что мы хотим подписаться и на свою собственную пользовательскую ленту тоже.

Откройте app/state/app_state.dart и измените его, как указано ниже:

...

/// Current user's [FlatFeed] with name 'user'.
///
/// This feed contains all of a user's personal posts.
FlatFeed get currentUserFeed => _client.flatFeed('user', user.id);

/// Current user's [FlatFeed] with name 'timeline'.
///
/// This contains all posts that a user has subscribed (followed) to.
FlatFeed get currentTimelineFeed => _client.flatFeed('timeline', user.id);

...

Далее, внесите изменения в метод connect, чтобы следить и за своей собственной лентой:

Future<bool> connect(DemoAppUser demoUser) async {
    final currentUser = await _client.setUser(
      User(id: demoUser.id),
      demoUser.token!,
      extraData: demoUser.data,
    );
    if (currentUser.data != null) {
      _streamagramUser = StreamagramUser.fromMap(currentUser.data!);
      await currentTimelineFeed.follow(currentUserFeed); // ADD THIS -> Follow own feed
      notifyListeners();
      return true;
    } else {
      return false;
    }
  }

Теперь если вы перезапустите приложение и зайдете как пользователь, вы должны видеть и свои посты в хронологической ленте.

Кэшируем состояние страниц

Возможно вы еще заметили, что страницы вашего приложения создаются заново каждый раз когда вы открываете их (используя PageView). Немного раздражает, не так ли?

К счастью, есть легкий способ как мы можем это исправить.

Откройте файл components/home/home_screen.dart в самом его низу добавьте данный класс:

class _KeepAlivePage extends StatefulWidget {
  const _KeepAlivePage({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

  @override
  _KeepAlivePageState createState() => _KeepAlivePageState();
}

class _KeepAlivePageState extends State<_KeepAlivePage>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);

    return widget.child;
  }

  @override
  bool get wantKeepAlive => true;
}

Он использует AutomaticKeepAliveClientMixin для того, чтобы поддерживать виджеты в рабочем состоянии после того как PageView взаимодействует с ними.

Все что вам нужно сделать это обернуть все страницы в виджете _KeepAlivePage. Измените виджет _homePages в соответствии с кодом ниже:

/// List of pages available from the home screen.
static const List<Widget> _homePages = <Widget>[
  _KeepAlivePage(child: TimelinePage()),
  _KeepAlivePage(child: SearchPage()),
  _KeepAlivePage(child: ProfilePage()),
];

После того как вы выполните быструю перезагрузку, вы должны увидеть, что ваши страницы не начинают загружаться заново по мере того как вы двигаетесь между вкладками.

Что дальше?

Ваше приложение готово! ????

При помощи Flutter и Stream Feeds вы смогли создать замечательное приложение-клон Instagram.  Поздравляем вас с этим!

Как уже не раз было сказано ранее, управление лентами может быть довольно сложным, при этом Feeds предлагает еще множество инструментов и функций, которые мы не успели затронуть в данной статье.

Следующим шагом для вас будет закрепление всего, что вы узнали в данной статье, и создание своих собственных приложений с функционалом Feeds. Рекомендуем вам прочитать больше про другие типы лент (например Aggregated и Notification), чтобы быть уверенным нужны ли они будут вашему новому приложению.

Мы всегда открыты для фидбэка и предложений. Пишите нам про что вы хотите увидеть новые видео и статьи в будущем.

Полную версию кода вы можете скачать по ссылке на Github.

Instagram* - запрещенная в России социальная сеть.

Также предлагаю подписаться на мой канал и на канал моего проекта:
Ссылка на канал проекта в телеграм: 
https://t.me/dom24x7
Ссылка на мой канал: 
https://t.me/evgaj

Комментарии (0)