Привет, Хабр! Меня зовут Андрей, я Data Engineer. В свободное время я решил написать свой «велосипед» — небольшую соцсеть для поиска единомышленников по нишевым интересам.

Спойлер: Google Sign-In на Flutter Web — это отдельный вид боли, но оно того стоило.

Ниже — полная история проекта Syncory, все «грабли», с которыми я столкнулся, и 100% открытый исходный код.

Проблема: «Как найти напарника для Warhammer?»

Всё началось с простого вопроса: «Как быстро найти людей по очень нишевым интересам?»

Мне, как инженеру, не хватало платформы, где можно отфильтровать людей не по друзьям или геолокации, а по конкретным категориям интересов: flutter, data-analysis, gamedev или warhammer-40k.

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

Так родился Syncory (ранее Synq) — pet-project, который быстро вырос в нечто большее, чем просто учебное приложение.

Выбор стека: простота превыше всего

Как Data Engineer, я не хотел тратить время на настройку серверов, оркестрацию контейнеров или администрирование баз данных. Мне нужно было решение, которое позволит сфокусироваться на логике приложения.

Бэкенд: 100% Serverless

Firebase — очевидный выбор:

  • Firebase Auth — готовая аутентификация из коробки

  • Firestore — NoSQL база данных в реальном времени

  • Бесплатный tier для старта проекта

  • Нулевая настройка инфраструктуры

Фронтенд: Flutter Web

Мне нужно было веб-приложение, доступное с любого ПК. Flutter Web подошёл идеально:

  • Быстрая разработка UI с использованием виджетов

  • Material Design 3 «из коробки»

  • Единая кодовая база для веба и мобильных платформ (на будущее)

  • Активное комьюнити и хорошая документация

Да, я знаю про React/Vue, но как человеку, который пишет на Python и SQL, синтаксис Dart показался мне более понятным и близким.

Что под капотом: ключевые фичи

Я хотел не просто «фид с постами», а полноценное приложение с продуманным UX. Вот что я реализовал:

1. Анимированная страница входа (wow-эффект)

Первое впечатление решает всё. Я отказался от скучного статичного лейаута с двумя колонками.

Решение: полноэкранный интерактивный фон с анимированными элементами.

Как это работает:

// Слой 1: Отслеживание позиции мыши
MouseRegion(
  onHover: (event) {
    setState(() {
      _mousePosition = event.position;
    });
  },
  child: Stack(
    children: [
      // Слой 2: Анимированные чипы
      ..._buildAnimatedChips(),
      
      // Слой 3: Форма входа
      _buildLoginCard(),
    ],
  ),
)

Фишка в _AnimatedChip: это StatefulWidget, который получает mousePosition и с помощью AnimatedContainer и Matrix4.translationValues плавно «убегает» от курсора.

Эффект минимальный, но он мгновенно вовлекает пользователя и создаёт ощущение «живого» приложения.

class _AnimatedChip extends StatefulWidget {
  final Offset mousePosition;
  final Offset initialPosition;
  final String label;
  // ...
}

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

2. Админка «по-взрослому» (роли и гейты)

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

Как реализовано:

Роль в Firestore:

{
  "users": {
    "user_id": {
      "email": "admin@example.com",
      "role": "admin",
      "isDisabled": false
    }
  }
}

Гейт в приложении:

// main_scaffold.dart
bool _isAdmin = false;

Future<void> _checkAdminStatus() async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) return;
  
  final doc = await FirebaseFirestore.instance
      .collection('users')
      .doc(user.uid)
      .get();
      
  setState(() {
    _isAdmin = doc.data()?['role'] == 'admin';
  });
}

Секретные кнопки в интерфейсе:

if (_isAdmin) ...[
  IconButton(
    icon: Icon(Icons.admin_panel_settings),
    onPressed: () => _navigateToAdminPanel(),
  ),
]

Система банов:

  • Админ устанавливает флаг isDisabled: true в документе пользователя

  • При следующем входе main.dart проверяет этот флаг

  • Если true — принудительный signOut() и редирект на LoginPage с сообщением о бане

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

3. Приватные комментарии (фича для вовлечения)

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

Логика:

На странице создания поста автор выбирает политику видимости комментариев:

SegmentedButton<String>(
  segments: [
    ButtonSegment(value: 'all', label: Text('Все')),
    ButtonSegment(value: 'approvedOnly', label: Text('Только одобренные')),
  ],
  selected: {_commentVisibility},
  onSelectionChanged: (Set<String> newSelection) {
    setState(() {
      _commentVisibility = newSelection.first;
    });
  },
)

На странице поста реализован гейт доступа:

class _CommentsAccessGate extends StatelessWidget {
  Widget build(BuildContext context) {
    final commentPolicy = post['commentVisibility'] ?? 'all';
    
    // Автор видит всё
    if (post['userId'] == currentUserId) {
      return _buildCommentsSection(showInput: true);
    }
    
    // Публичные комментарии
    if (commentPolicy == 'all') {
      return _buildCommentsSection(showInput: true);
    }
    
    // Приватные: проверяем статус заявки
    return StreamBuilder(
      stream: FirebaseFirestore.instance
          .collection('posts/${post.id}/applicants')
          .doc(currentUserId)
          .snapshots(),
      builder: (context, snapshot) {
        if (snapshot.data?.data()?['status'] == 'approved') {
          return _buildCommentsSection(showInput: true);
        }
        return _buildAccessDeniedMessage();
      },
    );
  }
}

Результат: пользователи более активно подают заявки, чтобы получить доступ к обсуждению.

4. Три круга ада: Google Sign-In на Flutter Web

А вот здесь началось самое интересное. Я наивно думал, что Google Auth — это просто await GoogleSignIn.instance.signIn().

Как же я ошибался.

Я потратил 3 дня на отладку, прежде чем всё заработало. Вот что нужно знать, если вы делаете Google Auth на Flutter Web:

Круг ада №1: OAuth Client ID vs Firebase

Проблема: Firebase Console даёт вам Web API Key, но для Google Sign-In на вебе этого недостаточно.

Решение: идём в Google Cloud Console → APIs & Services → Credentials и создаём OAuth 2.0 Client ID типа «Web application».

Круг ада №2: Redirect URI Mismatch

Проблема: получаете Error 400: redirect_uri_mismatch, хотя вроде всё настроили.

Решение: в настройках OAuth Client ID нужно добавить ваш URL в ДВА списка:

  1. Authorized JavaScript origins: https://your-app.web.app

  2. Authorized redirect URIs: https://your-app.web.app/__/auth/handler

Если пропустите второй — получите ошибку.

Круг ада №3: People API

Проблема: Error 403: PERMISSION_DENIED при попытке входа.

Решение: в Google Cloud Console → APIs & Services → Library ищем People API и включаем её для проекта.

Круг ада №4: Конфигурация в коде

После всех настроек в консолях, нужно правильно сконфигурировать Flutter:

В web/index.html:

<head>
  <meta name="google-signin-client_id" 
        content="YOUR-CLIENT-ID.apps.googleusercontent.com">
</head>

В login_page.dart:

final GoogleSignIn _googleSignIn = GoogleSignIn(
  scopes: [
    'email',
    'openid', // ОБЯЗАТЕЛЕН для получения idToken!
  ],
);

Future<void> _signInWithGoogle() async {
  try {
    final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();
    if (googleUser == null) return; // Пользователь отменил вход
    
    final GoogleSignInAuthentication googleAuth = 
        await googleUser.authentication;
    
    final OAuthCredential credential = GoogleAuthProvider.credential(
      idToken: googleAuth.idToken,
      // accessToken будет null на вебе!
    );
    
    await FirebaseAuth.instance.signInWithCredential(credential);
  } catch (e) {
    print('Error: $e');
  }
}

Важно: если видите призрачные ошибки типа Couldn't find constructor 'GoogleSignIn', хотя код правильный — выполните:

flutter clean
flutter pub get

И в VS Code: Ctrl+Shift+PReload Window.

Работа с Firestore: индексы и оптимизация

Firestore требует создания индексов для каждого сложного запроса. При первом выполнении нового запроса вы получите ошибку failed-precondition с ссылкой на создание индекса.

Пример:

// Такой запрос требует составного индекса
FirebaseFirestore.instance
    .collection('posts')
    .where('categories', arrayContains: 'flutter')
    .orderBy('createdAt', descending: true)
    .limit(20);

Решение: просто переходите по ссылке из ошибки в консоли, Firebase автоматически создаст нужный индекс. Через 1-2 минуты запрос заработает.

Что в итоге?

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

Технические метрики:

  • Время загрузки главной страницы: ~1.5 сек

  • Полностью serverless архитектура

  • Затраты на хостинг: $0 (в рамках бесплатного тира Firebase)

  • Время разработки: ~6 часов активного вайбкодинга

Благодарности

Отдельное огромное спасибо моему коллеге Aibol Nazenov (Айболу Назенову). Он был моим партнёром по брейнштормингу и принимал ключевое участие в разработке концепции Syncory. Без его фидбэка и критики проект не был бы таким, какой он есть сейчас.

Попробуйте сами (и посмотрите код)

Я выложил весь код на GitHub под лицензией GNU GPLv3.

? Живое демо: syncory-flutter-app.web.app

? GitHub: github.com/ungernthabaron/syncory-flutter-app

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

Вопрос к сообществу

А с какими «неочевидными» граблями Flutter Web или Firebase сталкивались вы? Особенно интересно услышать про проблемы с производительностью или специфичные кейсы интеграции.

Делитесь опытом в комментариях!


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


  1. DanielKross
    16.11.2025 12:36

    Зашел на демо страницу, все работает, все хорошо(на мой взгляд дизайн надо переработать), но неудобно, что когда заходишь в "пост", исчезает колонка слева, с темами, надо нажимать "назад", чтоб ее увидеть, наверное практичнее было бы менялся бы фрейм в середине страницы, а все остальное, оставалось бы в доступности. А за фишки с авторизацией гугла, спасибо, пригодится.


    1. GenomeDust Автор
      16.11.2025 12:36

      Спасибо за комментарий!


  1. dedm
    16.11.2025 12:36

    Сори ну меня чет забомбило нельзя так так делать
    Я переписывал раз 5 так как в 1ой версии были просто маты но ии меня остановил

    1. ОТСУТСТВИЕ STATE MANAGEMENT
      ────────────────────────────────────────────────────────
      Проект полностью игнорирует современные практики управления состоянием.
      Нет ни Provider, ни Riverpod, ни Bloc, ни даже простого InheritedWidget.

      Последствия:
      • Невозможно эффективно делиться состоянием между виджетами
      • Каждый виджет сам управляет своим состоянием через setState()
      • Дублирование логики проверки роли админа в MainScaffold, ProfilePage, PostDetailPage
      • Нет единого источника правды для данных пользователя
      • При изменении данных нужно вручную обновлять все зависимые виджеты

      Примеры проблем:

      • _checkUserRole() дублируется в 3+ местах

      • Данные пользователя загружаются заново в каждом виджете

      • Нет кеширования состояния аутентификации

    2. ОТСУТСТВИЕ СЛОЯ АБСТРАКЦИИ ДЛЯ ДАННЫХ
      ─────────────────────────────────────────────────────────
      Прямые вызовы FirebaseFirestore.instance и FirebaseAuth.instance везде.
      Нет репозиториев, нет сервисов, нет слоя данных.

      Проблемы:
      • Невозможно заменить Firebase на другой бэкенд без переписывания всего кода
      • Нет централизованной обработки ошибок
      • Нет кеширования данных
      • Невозможно протестировать бизнес-логику без моков Firebase
      • Дублирование логики работы с коллекциями

      Примеры:

      • FirebaseFirestore.instance.collection('users').doc(uid).get() повторяется 20+ раз

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

    3. ОТСУТСТВИЕ МОДЕЛЕЙ ДАННЫХ
      ─────────────────────────────────────────────────────────
      Везде используется Map вместо типизированных моделей.

      Последствия:
      • Нет автодополнения в IDE
      • Ошибки типов обнаруживаются только в runtime
      • Нет валидации данных при получении из Firebase
      • Сложно понять структуру данных без чтения кода
      • Легко допустить опечатку в ключах (например, 'author_name' vs 'authorName')

      Примеры:

      • data['displayName'] ?? 'Anonymous' - что если поле называется по-другому?

      • Нет гарантии, что все поля присутствуют

    4. СМЕШЕНИЕ БИЗНЕС-ЛОГИКИ И UI
      ─────────────────────────────────────────────────────────
      Вся бизнес-логика находится прямо в StatefulWidget.

      Проблемы:
      • Невозможно переиспользовать логику
      • Сложно тестировать
      • Виджеты становятся огромными (PostDetailPage - 714 строк!)
      • Нарушение принципа единственной ответственности

      Примеры:

      • toggleLike(), toggleBookmark(), _postComment() - это бизнес-логика, не UI

      • Вся логика работы с заявками (applicants) в UI-слое

    5. ОТСУТСТВИЕ ОБРАБОТКИ ОШИБОК
      ─────────────────────────────────────────────────────────
      Ошибки либо игнорируются, либо показываются через print().
      где трай кетч где runZonedGuarded

      Проблемы:
      • Нет централизованной обработки ошибок
      • Пользователь видит технические сообщения об ошибках
      • Нет логирования ошибок для отладки
      • Нет retry-логики при сетевых ошибках

      Примеры:

      • print("Error: $e") - бесполезно в production

      • catch (e) { _showError('Save Error: $e') } - показывает технические детали

    6. ОТСУТСТВИЕ ВАЛИДАЦИИ
      ─────────────────────────────────────────────────────────
      Минимальная валидация форм, нет проверки на стороне клиента.

      Проблемы:
      • Можно отправить пустые данные
      • Нет проверки формата email
      • Нет ограничений на длину текста
      • Нет проверки обязательных полей перед отправкой

      Примеры:

      • if (_titleController.text.isEmpty || _selectedCategories.isEmpty) - только базовая проверка

      • Нет валидации email в login_page.dart

    7. ПРОБЛЕМЫ С ПРОИЗВОДИТЕЛЬНОСТЬЮ
      ─────────────────────────────────────────────────────────
      Множественные StreamBuilder'ы, нет оптимизации запросов.

      Проблемы:
      • Один и тот же документ загружается несколько раз
      • Нет пагинации для списков (загружаются все посты сразу)
      • StreamBuilder'ы создаются без ограничений
      • Нет debounce для поиска
      • Фильтрация происходит на клиенте после загрузки всех данных

      Примеры:

      • FeedPage загружает все посты сразу, затем фильтрует на клиенте

      • PostDetailPage делает два FutureBuilder для одного и того же поста

      • Нет индексов для сложных запросов

    8. ОТСУТСТВИЕ ТЕСТОВ
      ─────────────────────────────────────────────────────────
      Только пустой widget_test.dart, нет unit-тестов, нет integration-тестов.
      Ладно тесты еще ок могу понять ну фаллатер аналйз же можно было настроить это базовая штука

    9. ОТСУТСТВИЕ КОНСТАНТ И КОНФИГУРАЦИИ
      ─────────────────────────────────────────────────────────
      Магические числа и строки везде, нет централизованных констант.

      Проблемы:
      • Сложно изменить значения (например, максимальную ширину контента)
      • Нет единого стиля для отступов, размеров шрифтов
      • Легко допустить ошибку при копировании значений

      Примеры:

      • BoxConstraints(maxWidth: 800) повторяется в нескольких местах

      • EdgeInsets.all(24) - разные значения в разных местах

      • Нет константы для названий коллекций Firebase

    10. ОТСУТСТВИЕ DEPENDENCY INJECTION
      ─────────────────────────────────────────────────────────
      Все зависимости создаются напрямую в коде.

      Проблемы:
      • Невозможно заменить реализации для тестирования
      • Тесная связанность компонентов
      • Сложно управлять жизненным циклом зависимостей

      Примеры:

      • FirebaseAuth.instance.currentUser - прямая зависимость

      • FirebaseFirestore.instance - везде напрямую

    11. НЕОПТИМАЛЬНАЯ СТРУКТУРА ПРОЕКТА
      ─────────────────────────────────────────────────────────
      Все файлы в корне lib/, нет разделения на слои.

      Проблемы:
      • Сложно найти нужный код
      • Нет разделения на features/modules
      • Нет разделения на layers (data, domain, presentation)
      • Все в одном месте

      Рекомендуемая структура:
      lib/
      core/
      constants/
      errors/
      utils/
      data/
      models/
      repositories/
      services/
      domain/
      entities/
      usecases/
      presentation/
      pages/
      widgets/
      providers/

    12. ПРОБЛЕМЫ С БЕЗОПАСНОСТЬЮ
      ─────────────────────────────────────────────────────────
      Отсутствие проверок прав доступа на клиенте, утечки данных.

      Проблемы:
      • Проверка роли админа только на клиенте (можно обойти)
      • Нет проверки прав перед выполнением действий
      • Потенциальные утечки данных через ошибки
      • Нет шифрования чувствительных данных

      Примеры:

      • _isAdmin проверяется только на клиенте

      • Нет валидации на сервере (Firestore Rules должны быть строгими)

    13. НЕСООТВЕТСТВИЕ BEST PRACTICES FLUTTER
      ─────────────────────────────────────────────────────────
      Игнорирование рекомендаций Flutter team.

      Проблемы:
      • Нет использования const конструкторов где возможно
      • Нет оптимизации rebuild'ов
      • Неправильное использование StreamBuilder (множественные подписки)
      • Нет использования keys где необходимо
      • Неоптимальное использование ListView.builder

      Примеры:

      • Отсутствие const для статических виджетов

      • StreamBuilder без ограничений

      • Нет использования AutomaticKeepAliveClientMixin

    14. ОТСУТСТВИЕ ОБРАБОТКИ СОСТОЯНИЙ ЗАГРУЗКИ
      ─────────────────────────────────────────────────────────
      Примитивная обработка состояний загрузки, нет skeleton screens.

      Проблемы:
      • Пользователь видит только CircularProgressIndicator
      • Нет обработки пустых состояний (empty states) везде
      • Нет обработки состояний ошибок
      • Нет retry механизмов
      • Почти нету if (!mounted) return;

    15. ПРОБЛЕМЫ С ДОСТУПНОСТЬЮ
      ─────────────────────────────────────────────────────────
      Отсутствие поддержки accessibility.

      Проблемы:
      • Нет semantic labels
      • Нет поддержки screen readers
      • Нет поддержки больших шрифтов
      • Нет поддержки высокого контраста

    16. ОТСУТСТВИЕ ОФФЛАЙН-ПОДДЕРЖКИ
      ───────────────────────────────────────────────────────────────────────────────
      Приложение не работает без интернета.

      Проблемы:
      • Нет кеширования данных
      • Нет синхронизации при восстановлении соединения
      • Нет индикации офлайн-режима
      • Пользователь теряет данные при обрыве соединения

    ══════════════════════════════════════════════════════════════
    ИТОГОВАЯ ОЦЕНКА
    ══════════════════════════════════════════════════════════════

    Этот проект демонстрирует классические антипаттерны разработки Flutter приложений:

    1. ❌ Нет архитектуры - все в одном слое

    2. ❌ Нет state management - невозможно масштабировать

    3. ❌ Нет абстракций - тесная связанность с Firebase

    4. ❌ Нет тестов - нет гарантий качества

    5. ❌ Нет документации - сложно поддерживать

    6. ❌ Проблемы с производительностью - неоптимальные запросы

    7. ❌ Проблемы с безопасностью - проверки только на клиенте

    8. ❌ Несоблюдение best practices - игнорирование рекомендаций

    Это получился очень сырой и хаотичный код.
    Такое ощущение, что архитектура и принципы ООП просто не учитывались.
    С таким подходом проект действительно может «ложиться» в проде очень быстро — не потому что он безнадёжен, а потому что фундамент не продуман.
    И, пожалуйста, не оправдывай это «вайб-кодингом» — тут реально видно, что проблема не в стиле, а в отсутствии структуры.
    Даже нейросеть не стала бы генерировать подобный набор противоречий — это просто результат того, что ты писал на ходу, без плана и слоёв абстракции.

    ══════════════════════════════════════════════════════════════


    1. GenomeDust Автор
      16.11.2025 12:36

      да, на самом деле так и было, что логика повлялась вместо с кодом, плана не было,

      Спасибо за коммент, постараюсь применить рекомендации в свободное время!


      1. dedm
        16.11.2025 12:36

        Тут дело вообще не в “плане”, а в том, что ты, похоже, не понял базовую парадигму Flutter.
        Это реактивный фреймворк — он не перерисовывает всю страницу, только те виджеты, которые подписаны на состояние.

        Поэтому state-management (Provider / Riverpod / Bloc) — это не “совет”, а нормальная архитектура.

        А вот императивный стиль “как в Python”, когда логика смешана с UI — это уже точно не про Flutter.


    1. dkfbm
      16.11.2025 12:36

      Сори ну меня чет забомбило нельзя так так делать

      Github не смотрел, но охотно верю, что всё так и есть – мне уже в примерах кода метод присвоения роли админа глаз резанул. Про ACL автор, очевидно, не слышал. Да и читая про "круги ада" всё время вспоминалось сакральное: RTFM.

      Это не говоря уж о том, что в любой соцсети фронтенд – лишь надводная часть айсберга. Без развитой админки она попросту не поплывёт, а тут даже зачатков нет, и ни о каких планах создания не упоминается.


  1. smessing
    16.11.2025 12:36

    Наивный думает одного приложения сети - хватит. А зачем концепт ?)... Главное уметь программировать, и начать описывать функционал :)